vonpay-medusa 1.2.1 → 1.4.0

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.
Files changed (76) hide show
  1. package/.medusa/server/src/admin/index.js +437 -129
  2. package/.medusa/server/src/admin/index.mjs +438 -130
  3. package/.medusa/server/src/api/admin/vonpay/holds/route.js +69 -3
  4. package/.medusa/server/src/api/admin/vonpay/offers/report/route.js +10 -0
  5. package/.medusa/server/src/api/admin/vonpay/subscriptions/route.js +15 -1
  6. package/.medusa/server/src/api/admin/vonpay/update/route.js +2 -1
  7. package/.medusa/server/src/api/admin/vonpay/variants/[id]/subscription/route.js +17 -0
  8. package/.medusa/server/src/api/middlewares.js +2 -0
  9. package/.medusa/server/src/api/store/vonpay/upsells/[order]/accept/route.js +11 -1
  10. package/.medusa/server/src/api/store/vonpay/upsells/[order]/shown/route.js +21 -0
  11. package/.medusa/server/src/lib/card-brand.js +4 -0
  12. package/.medusa/server/src/lib/errors.js +29 -1
  13. package/.medusa/server/src/lib/held-charge-copy.js +107 -2
  14. package/.medusa/server/src/lib/money.js +45 -0
  15. package/.medusa/server/src/lib/reopen-reason.js +26 -0
  16. package/.medusa/server/src/lib/store-metadata.js +53 -0
  17. package/.medusa/server/src/lib/update-check.js +237 -17
  18. package/.medusa/server/src/lib/update-timestamp-state.js +49 -0
  19. package/.medusa/server/src/modules/vonpay-jobs/migrations/Migration20261107202021.js +26 -0
  20. package/.medusa/server/src/modules/vonpay-jobs/migrations/Migration20261109162717.js +15 -0
  21. package/.medusa/server/src/modules/vonpay-jobs/migrations/Migration20261111111134.js +18 -0
  22. package/.medusa/server/src/modules/vonpay-jobs/migrations/Migration20261111164946.js +13 -0
  23. package/.medusa/server/src/modules/vonpay-jobs/models/refund-watch.js +44 -0
  24. package/.medusa/server/src/modules/vonpay-jobs/models/renewal-period.js +48 -0
  25. package/.medusa/server/src/modules/vonpay-jobs/models/signup-watch.js +28 -0
  26. package/.medusa/server/src/modules/vonpay-jobs/service.js +202 -1
  27. package/.medusa/server/src/modules/vonpay-jobs/types.js +2 -0
  28. package/.medusa/server/src/modules/vonpay-subscriptions/migrations/Migration20261102180000.js +13 -0
  29. package/.medusa/server/src/modules/vonpay-subscriptions/models/subscription-event.js +4 -0
  30. package/.medusa/server/src/modules/vonpay-upsells/migrations/Migration20261102113326.js +20 -0
  31. package/.medusa/server/src/modules/vonpay-upsells/migrations/Migration20261102170444.js +14 -0
  32. package/.medusa/server/src/modules/vonpay-upsells/models/event.js +8 -2
  33. package/.medusa/server/src/modules/vonpay-upsells/service.js +138 -0
  34. package/.medusa/server/src/providers/vonpay/admin-holds.js +14 -6
  35. package/.medusa/server/src/providers/vonpay/charge-hold.js +87 -2
  36. package/.medusa/server/src/providers/vonpay/descriptor-rules.js +6 -1
  37. package/.medusa/server/src/providers/vonpay/follow-up.js +45 -0
  38. package/.medusa/server/src/providers/vonpay/held-list.js +223 -3
  39. package/.medusa/server/src/providers/vonpay/hold-actions.js +1 -0
  40. package/.medusa/server/src/providers/vonpay/job-beat.js +11 -2
  41. package/.medusa/server/src/providers/vonpay/options.js +8 -0
  42. package/.medusa/server/src/providers/vonpay/refund-verdict.js +15 -0
  43. package/.medusa/server/src/providers/vonpay/refund-watch.js +562 -0
  44. package/.medusa/server/src/providers/vonpay/renewal-check.js +14 -0
  45. package/.medusa/server/src/providers/vonpay/renewal-period.js +807 -0
  46. package/.medusa/server/src/providers/vonpay/renewal-recovery.js +2 -0
  47. package/.medusa/server/src/providers/vonpay/renewal.js +387 -106
  48. package/.medusa/server/src/providers/vonpay/scan.js +13 -0
  49. package/.medusa/server/src/providers/vonpay/service.js +910 -51
  50. package/.medusa/server/src/providers/vonpay/session-data.js +245 -3
  51. package/.medusa/server/src/providers/vonpay/setup-check.js +260 -6
  52. package/.medusa/server/src/providers/vonpay/signup-api.js +176 -0
  53. package/.medusa/server/src/providers/vonpay/signup-watch.js +303 -0
  54. package/.medusa/server/src/providers/vonpay/signup.js +171 -0
  55. package/.medusa/server/src/providers/vonpay/subscription-cancel.js +168 -0
  56. package/.medusa/server/src/providers/vonpay/subscription-events.js +93 -4
  57. package/.medusa/server/src/providers/vonpay/subscription-products-admin.js +97 -0
  58. package/.medusa/server/src/providers/vonpay/upsell-accept.js +470 -40
  59. package/.medusa/server/src/providers/vonpay/upsell-cleanup.js +88 -5
  60. package/.medusa/server/src/providers/vonpay/upsell-report.js +489 -0
  61. package/.medusa/server/src/providers/vonpay/upsell-routes.js +9 -1
  62. package/.medusa/server/src/providers/vonpay/wallet-domains.js +13 -44
  63. package/.medusa/server/src/subscribers/vonpay-offer-refunds.js +9 -0
  64. package/README.md +441 -14
  65. package/bin/vonpay-medusa.cjs +26 -16
  66. package/package.json +4 -2
  67. package/storefront/src/copy.ts +15 -0
  68. package/storefront/src/index.ts +1 -1
  69. package/storefront/src/outcome.ts +37 -0
  70. package/storefront/src/reload-once.ts +13 -0
  71. package/storefront/src/server.ts +37 -6
  72. package/storefront/src/upsell.ts +43 -4
  73. package/storefront/src/vonpay-card-payment.tsx +38 -3
  74. package/storefront/src/vonpay-payment-view.tsx +43 -1
  75. package/storefront/src/vonpay-upsell-offer.tsx +95 -11
  76. package/storefront/src/vonpay-upsell-return.tsx +2 -4
@@ -13,6 +13,9 @@ const TIME_SLOT = "{time}";
13
13
  function fillTime(text, at, format) {
14
14
  return at && text.includes(TIME_SLOT) ? text.split(TIME_SLOT).join(format(at)) : text;
15
15
  }
16
+ const SUBSCRIPTION_NOT_NAMED_TEXT = {
17
+ unknown: "Not known yet. In the Vonpay dashboard, find the subscription by this order's id."
18
+ };
16
19
  const NOT_SAVED_TEXT = "That couldn't be saved. The list is read again: check the row as it stands now before you try again.";
17
20
  const CHARGE_REASON_LABEL = {
18
21
  renewal_outcome_unknown: "Renewal charge — result unknown",
@@ -31,8 +34,23 @@ const CHARGE_REASON_LABEL = {
31
34
  upsell_refund_unclear: "Late offer charge — refund unclear",
32
35
  upsell_refund_pending: "Late offer charge — refund in progress",
33
36
  upsell_refunded_late: "Late offer charge refunded",
37
+ upsell_refund_requested: "Late offer charge — refund accepted, not finished",
34
38
  upsell_hold_unsettled: "Offer payment held — not finished yet",
35
- upsell_take_unconfirmed: "Offer payment — collection not confirmed"
39
+ upsell_take_unconfirmed: "Offer payment — collection not confirmed",
40
+ upsell_subscription_active: "Offer refund — cancel its subscription",
41
+ upsell_subscription_ended: "Offer refund — subscription ended",
42
+ refund_watch_canceled: "Refunded in Medusa — Vonpay did not complete it",
43
+ refund_watch_failed: "Refunded in Medusa — Vonpay's refund failed",
44
+ refund_watch_unclear: "Refunded in Medusa — Vonpay's refund failed, outcome unclear",
45
+ refund_watch_overdue: "Refunded in Medusa — Vonpay's refund not finished",
46
+ refund_watch_resolved: "Refunded in Medusa — Vonpay's refund settled by a person",
47
+ signup_watch_missing: "Subscription paid for — not started",
48
+ signup_watch_resolved: "Subscription paid for — settled by a person",
49
+ renewal_period_paid_refused: "Renewal refused — this period is already paid",
50
+ renewal_period_declined: "Renewal declined — ReorderJS keeps asking",
51
+ renewal_period_stuck: "Earlier renewal charge not finished",
52
+ renewal_period_closed: "Renewal period kept blocked by a person",
53
+ renewal_period_reopened: "Renewal period reopened by a person"
36
54
  };
37
55
  const CHARGE_RESOLVED_HOW_LABEL = {
38
56
  closed_blocking: "Closed by a person — no more renewal charges for this order",
@@ -75,6 +93,10 @@ const REASON_COPY = {
75
93
  label: "Couldn't confirm amount",
76
94
  what: "The cart total couldn't be read to confirm the charge, so no order was created."
77
95
  },
96
+ signup_mismatch: {
97
+ label: "Subscription doesn't match the cart",
98
+ what: "The buyer paid, but the subscription this payment started doesn't match the items in the cart, so no order was created. A refund here doesn't cancel the subscription: cancel it in Vonpay too."
99
+ },
78
100
  binding_mismatch: {
79
101
  label: "Payment doesn't match checkout",
80
102
  what: "The payment belongs to a different checkout than the one completing."
@@ -158,7 +180,10 @@ const SOURCE_LABEL = {
158
180
  cancel: "Recorded when the order was cancelled",
159
181
  renewal: "Recorded by a subscription renewal — check in Vonpay",
160
182
  order: "Found on an order — its wallet payment wasn't collected",
161
- upsell: "Recorded by an offer accepted after the order — check in Vonpay"
183
+ upsell: "Recorded by an offer accepted after the order — check in Vonpay",
184
+ refund: "Recorded when Vonpay did not complete a refund Medusa shows as done",
185
+ signup: "Recorded when Vonpay named no subscription for a subscription payment",
186
+ period: "Recorded by the plugin's one-charge-per-period record for subscription renewals"
162
187
  };
163
188
  const STATUS_LABEL = {
164
189
  open: "Needs action",
@@ -212,9 +237,11 @@ const REFUNDABLE_REASONS = [
212
237
  "paid_orphan_session",
213
238
  "paid_after_auto_close",
214
239
  "paid_no_order",
240
+ "signup_mismatch",
215
241
  "cancel_refund_missing",
216
242
  "cancel_refund_unconfirmed"
217
243
  ];
244
+ const SUBSCRIPTION_REFUND_NOTE = "If this payment started a Vonpay subscription, refunding it does not cancel the subscription: cancel it in your Vonpay dashboard.";
218
245
  function isConfirmedRefund(row) {
219
246
  return row.status === "resolved" && (row.resolution?.how === "refunded" || row.resolution?.how === "refunded_earlier");
220
247
  }
@@ -222,7 +249,7 @@ function canRefund(row) {
222
249
  return row.vp_session_id !== null && !isConfirmedRefund(row) && !isAutoClosed(row) && !isUnrecognisedRecord(row) && REFUNDABLE_REASONS.includes(row.reason);
223
250
  }
224
251
  function isChargeRow(row) {
225
- return row.source === "renewal" || row.source === "upsell";
252
+ return row.source === "renewal" || row.source === "upsell" || row.source === "refund" || row.source === "signup" || row.source === "period";
226
253
  }
227
254
  function canRelease(row) {
228
255
  return row.source === "collection" && row.vp_session_id !== null && row.status === "open" && WALLET_REASONS.includes(row.reason);
@@ -235,6 +262,9 @@ function canMarkResolved(row) {
235
262
  return row.vp_session_id !== null && row.status !== "resolved" && !isUnrecognisedRecord(row) && !WALLET_REASONS.includes(row.reason) && !TAKE_REASONS.includes(row.reason);
236
263
  }
237
264
  function locatorFor(row) {
265
+ if (row.source === "refund") return { source: "refund", watch_id: row.watch_id ?? "" };
266
+ if (row.source === "signup") return { source: "signup", watch_id: row.watch_id ?? "" };
267
+ if (row.source === "period") return { source: "period", period_id: row.watch_id ?? "" };
238
268
  if (isChargeRow(row)) {
239
269
  return {
240
270
  source: row.source,
@@ -559,6 +589,7 @@ function useChargeAction() {
559
589
  ...locatorFor(v.row),
560
590
  action: v.action,
561
591
  ...v.typed_order_id !== void 0 ? { confirm_order_id: v.typed_order_id.trim(), confirm_checked_in_vonpay: v.checked_in_vonpay === true } : {},
592
+ ...v.reason !== void 0 ? { confirm_reason: v.reason } : {},
562
593
  ...v.action === "close_unreadable" && v.row.record_sha256 ? { confirm_record_sha256: v.row.record_sha256 } : {}
563
594
  }
564
595
  }),
@@ -596,11 +627,11 @@ const HeldPaymentsBanner = () => {
596
627
  ] }) });
597
628
  };
598
629
  adminSdk.defineWidgetConfig({ zone: "order.list.before" });
599
- const KEY$1 = "vonpay-ship-hold";
630
+ const KEY$2 = "vonpay-ship-hold";
600
631
  const SHIP_HOLD_REFRESH_MS = 3e4;
601
632
  function useShipHold(orderId) {
602
633
  return reactQuery.useQuery({
603
- queryKey: [KEY$1, orderId],
634
+ queryKey: [KEY$2, orderId],
604
635
  queryFn: () => sdk.client.fetch(`/admin/vonpay/orders/${encodeURIComponent(orderId)}/offer-hold`),
605
636
  enabled: !!orderId,
606
637
  retry: false,
@@ -615,7 +646,7 @@ function useShipOverride(orderId) {
615
646
  return reactQuery.useMutation({
616
647
  mutationFn: () => sdk.client.fetch(`/admin/vonpay/orders/${encodeURIComponent(orderId)}/ship-override`, { method: "POST", body: {} }),
617
648
  retry: false,
618
- onSettled: () => qc.invalidateQueries({ queryKey: [KEY$1, orderId] })
649
+ onSettled: () => qc.invalidateQueries({ queryKey: [KEY$2, orderId] })
619
650
  });
620
651
  }
621
652
  function clockTime(iso) {
@@ -644,8 +675,18 @@ function useUpdateNotice() {
644
675
  retry: false
645
676
  });
646
677
  }
678
+ const CHANGELOG_URL = "https://docs.vonpay.com/connected-platforms/medusa-changelog";
647
679
  const UpdateBanner = () => {
648
680
  const { data } = useUpdateNotice();
681
+ if (data?.status === "unavailable" && data.reason === "unconfirmed") {
682
+ return /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "small", className: "text-ui-fg-subtle", "data-testid": "vonpay-update-unconfirmed", children: [
683
+ "Couldn't confirm plugin updates — check",
684
+ " ",
685
+ /* @__PURE__ */ jsxRuntime.jsx("a", { href: CHANGELOG_URL, target: "_blank", rel: "noreferrer", className: "text-ui-fg-interactive hover:text-ui-fg-interactive-hover", children: "docs.vonpay.com" }),
686
+ " ",
687
+ "for the latest version."
688
+ ] });
689
+ }
649
690
  if (!data || data.status !== "update") return null;
650
691
  return /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: data.security ? "error" : "info", "data-testid": "vonpay-update-banner", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "small", children: [
651
692
  data.security ? "Security fix: " : "",
@@ -664,6 +705,76 @@ const UpdateBanner = () => {
664
705
  ] }) });
665
706
  };
666
707
  adminSdk.defineWidgetConfig({ zone: "order.list.before" });
708
+ const KEY$1 = "vonpay-variant-subscription";
709
+ function useVariantSubscription(variantId) {
710
+ return reactQuery.useQuery({
711
+ queryKey: [KEY$1, variantId],
712
+ queryFn: () => sdk.client.fetch(`/admin/vonpay/variants/${encodeURIComponent(variantId)}/subscription`),
713
+ enabled: !!variantId,
714
+ retry: false
715
+ });
716
+ }
717
+ function useSetVariantSubscription(variantId) {
718
+ const qc = reactQuery.useQueryClient();
719
+ return reactQuery.useMutation({
720
+ mutationFn: (on) => sdk.client.fetch(`/admin/vonpay/variants/${encodeURIComponent(variantId)}/subscription`, { method: "POST", body: { on } }),
721
+ retry: false,
722
+ onSettled: () => qc.invalidateQueries({ queryKey: [KEY$1, variantId] })
723
+ });
724
+ }
725
+ const VARIANT_SUBSCRIPTION_COPY = {
726
+ heading: "Vonpay subscription",
727
+ on: "Sold as a Vonpay subscription: buying it at checkout (or on an offer) starts a subscription that renews in Vonpay.",
728
+ off: "Sold as a one-time purchase.",
729
+ noProduct: "No Vonpay subscription product has this variant's id as its Store product number. Create one in your Vonpay dashboard (Subscriptions → Products) and type this id there:",
730
+ markedNoProduct: "Vonpay has no active subscription product for this variant, so its checkout is refused. Create or re-activate the product in your Vonpay dashboard with this id as its Store product number, or stop selling it as a subscription:",
731
+ turnOn: "Sell as a Vonpay subscription",
732
+ turnOff: "Stop selling as a subscription",
733
+ failed: "Couldn't change that. Reload and try again.",
734
+ unreadable: "Couldn't read Vonpay's subscription products. Reload to try again."
735
+ };
736
+ const VonpayVariantSubscription = ({ data: variant }) => {
737
+ const { data, isError } = useVariantSubscription(variant?.id);
738
+ const set = useSetVariantSubscription(variant?.id);
739
+ if (isError)
740
+ return /* @__PURE__ */ jsxRuntime.jsx(ui.Container, { className: "px-6 py-4", "data-testid": "vonpay-variant-subscription", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-error", children: VARIANT_SUBSCRIPTION_COPY.unreadable }) });
741
+ if (!data || !data.enabled) return null;
742
+ const product = data.products.find((p) => p.status === "active") ?? data.products[0];
743
+ return /* @__PURE__ */ jsxRuntime.jsxs(ui.Container, { className: "flex flex-col gap-y-3 px-6 py-4", "data-testid": "vonpay-variant-subscription", children: [
744
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between", children: [
745
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h2", children: VARIANT_SUBSCRIPTION_COPY.heading }),
746
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Badge, { color: data.marked ? "green" : "grey", size: "2xsmall", children: data.marked ? "On" : "Off" })
747
+ ] }),
748
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: data.marked ? VARIANT_SUBSCRIPTION_COPY.on : VARIANT_SUBSCRIPTION_COPY.off }),
749
+ product?.disclosure ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", "data-testid": "vonpay-variant-subscription-terms", children: product.disclosure }) : null,
750
+ !data.can_mark && !data.marked ? /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "small", className: "text-ui-fg-subtle", "data-testid": "vonpay-variant-subscription-setup", children: [
751
+ VARIANT_SUBSCRIPTION_COPY.noProduct,
752
+ " ",
753
+ /* @__PURE__ */ jsxRuntime.jsx("code", { children: variant?.id })
754
+ ] }) : null,
755
+ !data.can_mark && data.marked ? /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "small", className: "text-ui-fg-error", "data-testid": "vonpay-variant-subscription-broken", children: [
756
+ VARIANT_SUBSCRIPTION_COPY.markedNoProduct,
757
+ " ",
758
+ /* @__PURE__ */ jsxRuntime.jsx("code", { children: variant?.id })
759
+ ] }) : null,
760
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: data.marked ? /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "secondary", isLoading: set.isPending, onClick: () => set.mutate(false), "data-testid": "vonpay-variant-subscription-off", children: VARIANT_SUBSCRIPTION_COPY.turnOff }) : /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", disabled: !data.can_mark, isLoading: set.isPending, onClick: () => set.mutate(true), "data-testid": "vonpay-variant-subscription-on", children: VARIANT_SUBSCRIPTION_COPY.turnOn }) }),
761
+ set.isError ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-error", children: VARIANT_SUBSCRIPTION_COPY.failed }) : null
762
+ ] });
763
+ };
764
+ adminSdk.defineWidgetConfig({ zone: "product_variant.details.after" });
765
+ const REOPEN_REASON = Object.freeze({ min: 3, max: 200, cardDigits: 12 });
766
+ const INVISIBLE = /[\p{Cc}\p{Cf}\u2028\u2029]/gu;
767
+ const DIGIT_SEPARATORS = new RegExp("(?<=\\p{Nd})[^\\p{L}\\p{Nd}]+(?=\\p{Nd})", "gu");
768
+ const CARD_RUN = new RegExp(`\\p{Nd}{${REOPEN_REASON.cardDigits},}`, "u");
769
+ function reopenReasonOf(raw) {
770
+ if (typeof raw !== "string") return { ok: false, why: "reason_required" };
771
+ const text = raw.normalize("NFKC").replace(INVISIBLE, "").trim();
772
+ if (CARD_RUN.test(text.replace(DIGIT_SEPARATORS, ""))) return { ok: false, why: "reason_card_number" };
773
+ const length = reasonLength(text);
774
+ if (length < REOPEN_REASON.min || length > REOPEN_REASON.max) return { ok: false, why: "reason_required" };
775
+ return { ok: true, reason: text };
776
+ }
777
+ const reasonLength = (text) => [...text].length;
667
778
  function useSetupCheck() {
668
779
  return reactQuery.useMutation({
669
780
  mutationFn: () => sdk.client.fetch("/admin/vonpay/setup-check", { method: "POST", body: {} }),
@@ -806,7 +917,8 @@ const RefundAction = ({ row, onResult }) => {
806
917
  /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt, { open, onOpenChange: (o) => !refund.isPending && setOpen(o), variant: "confirmation", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Content, { children: [
807
918
  /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Header, { children: [
808
919
  /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Title, { children: "Refund this payment in full?" }),
809
- /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Description, { children: description })
920
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Description, { children: description }),
921
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", "data-testid": "vonpay-refund-subscription-note", children: SUBSCRIPTION_REFUND_NOTE })
810
922
  ] }),
811
923
  /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Footer, { children: [
812
924
  /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Cancel, { disabled: refund.isPending, children: "Cancel" }),
@@ -908,8 +1020,10 @@ const ChargeActions = ({ row, listAt, onResult }) => {
908
1020
  const [asking, setAsking] = react.useState(null);
909
1021
  const [typed, setTyped] = react.useState("");
910
1022
  const [checked, setChecked] = react.useState(false);
1023
+ const [reason, setReason] = react.useState("");
911
1024
  const act = useChargeAction();
912
1025
  const qc = reactQuery.useQueryClient();
1026
+ const typedFor = (a) => a.confirm === "type_order_id" ? { what: "order id", value: a.confirm_id ?? row.order_id } : a.confirm === "type_payment_id" ? { what: "Vonpay payment id", value: a.confirm_id ?? null } : null;
913
1027
  const rowSays = JSON.stringify([row.held_at, row.actions ?? null, row.hint ?? null]);
914
1028
  react.useEffect(() => setAnswer(null), [rowSays]);
915
1029
  const actions = answer?.actions ?? row.actions ?? [];
@@ -937,12 +1051,12 @@ const ChargeActions = ({ row, listAt, onResult }) => {
937
1051
  return () => clearTimeout(timer);
938
1052
  }, [pastDue, listAt, qc]);
939
1053
  const run = (a) => act.mutate(
940
- { row, action: a.id, ...a.confirm === "type_order_id" ? { typed_order_id: typed, checked_in_vonpay: checked } : {} },
1054
+ { row, action: a.id, ...typedFor(a) !== null ? { typed_order_id: typed, checked_in_vonpay: checked } : {}, ...a.confirm_reason ? { reason } : {} },
941
1055
  {
942
1056
  onSuccess: (o) => {
943
1057
  if (o.recorded === false || o.outcome === "not_saved") {
944
1058
  setAnswer({ ...o, actions: [], hint: null, receivedAt: Date.now() });
945
- onResult({ variant: "warning", text: NOT_SAVED_TEXT });
1059
+ onResult({ variant: "warning", text: o.outcome === "not_saved" && typeof o.text === "string" && o.text ? o.text : NOT_SAVED_TEXT });
946
1060
  void qc.invalidateQueries({ queryKey: [HELD_LIST_KEY] });
947
1061
  return;
948
1062
  }
@@ -956,8 +1070,10 @@ const ChargeActions = ({ row, listAt, onResult }) => {
956
1070
  onSettled: () => setAsking(null)
957
1071
  }
958
1072
  );
959
- const typedOk = !asking || asking.confirm !== "type_order_id" || typed.trim() === row.order_id && checked;
960
- const inputId = `vonpay-charge-confirm-${row.payment_collection_id}-${row.medusa_payment_session_id ?? row.acceptance_id ?? ""}`;
1073
+ const expected = asking ? typedFor(asking) : null;
1074
+ const reasonOk = !asking?.confirm_reason || reopenReasonOf(reason).ok;
1075
+ const typedOk = (!asking || expected === null || typed.trim() === expected.value && checked) && reasonOk;
1076
+ const inputId = `vonpay-charge-confirm-${row.payment_collection_id}-${row.medusa_payment_session_id ?? row.acceptance_id ?? ""}-${row.watch_id ?? ""}`;
961
1077
  return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
962
1078
  hint && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: fillTime(hint.text, hint.at, localTime) }),
963
1079
  actions.length > 0 && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex flex-wrap gap-2", children: actions.map((a) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
@@ -976,6 +1092,7 @@ const ChargeActions = ({ row, listAt, onResult }) => {
976
1092
  }
977
1093
  setTyped("");
978
1094
  setChecked(false);
1095
+ setReason("");
979
1096
  setAsking(a);
980
1097
  },
981
1098
  children: a.label
@@ -994,10 +1111,12 @@ const ChargeActions = ({ row, listAt, onResult }) => {
994
1111
  ] }),
995
1112
  /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Description, { children: asking?.confirm_text })
996
1113
  ] }),
997
- asking?.confirm === "type_order_id" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 pb-4", children: [
1114
+ expected !== null && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 pb-4", children: [
998
1115
  /* @__PURE__ */ jsxRuntime.jsxs(ui.Label, { htmlFor: inputId, size: "small", children: [
999
- "Type the order id to confirm: ",
1000
- /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono", children: row.order_id })
1116
+ "Type the ",
1117
+ expected.what,
1118
+ " to confirm: ",
1119
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "font-mono", children: expected.value })
1001
1120
  ] }),
1002
1121
  /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: inputId, size: "small", autoComplete: "off", value: typed, onChange: (e) => setTyped(e.target.value) }),
1003
1122
  /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex items-center gap-x-2", children: [
@@ -1005,6 +1124,16 @@ const ChargeActions = ({ row, listAt, onResult }) => {
1005
1124
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "txt-compact-small", children: "I checked this order in the Vonpay dashboard" })
1006
1125
  ] })
1007
1126
  ] }),
1127
+ asking?.confirm_reason && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 pb-4", children: [
1128
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Label, { htmlFor: `${inputId}-reason`, size: "small", children: [
1129
+ "Reason (",
1130
+ REOPEN_REASON.min,
1131
+ " to ",
1132
+ REOPEN_REASON.max,
1133
+ " characters; never a card number — any 12 or more digits in a row are refused, a phone number too)"
1134
+ ] }),
1135
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: `${inputId}-reason`, size: "small", autoComplete: "off", maxLength: REOPEN_REASON.max, value: reason, onChange: (e) => setReason(e.target.value) })
1136
+ ] }),
1008
1137
  /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Footer, { children: [
1009
1138
  /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Cancel, { disabled: act.isPending, children: "Cancel" }),
1010
1139
  /* @__PURE__ */ jsxRuntime.jsx(
@@ -1103,6 +1232,14 @@ const HoldDrawer = ({ row, listAt, onClose }) => {
1103
1232
  /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Vonpay payment" }),
1104
1233
  /* @__PURE__ */ jsxRuntime.jsx("dd", { children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.vp_payment_intent_id, label: "Vonpay payment id" }) })
1105
1234
  ] }),
1235
+ row.vp_refund_id && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1236
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Vonpay refund" }),
1237
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { "data-testid": "held-row-refund", children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.vp_refund_id, label: "Vonpay refund id" }) })
1238
+ ] }),
1239
+ row.subscription_id !== void 0 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1240
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Vonpay subscription" }),
1241
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { "data-testid": "held-row-subscription", children: row.subscription_id ? /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.subscription_id, label: "Vonpay subscription id" }) : /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: row.subscription_id_note ?? SUBSCRIPTION_NOT_NAMED_TEXT.unknown }) })
1242
+ ] }),
1106
1243
  /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Vonpay session" }),
1107
1244
  /* @__PURE__ */ jsxRuntime.jsx("dd", { children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.vp_session_id, label: "Vonpay session id" }) }),
1108
1245
  /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Medusa payment session" }),
@@ -1140,7 +1277,7 @@ const HoldDrawer = ({ row, listAt, onClose }) => {
1140
1277
  showRefund && /* @__PURE__ */ jsxRuntime.jsx(CheckWithVonpayAction, { row, onResult: setResult }),
1141
1278
  showMark && /* @__PURE__ */ jsxRuntime.jsx(MarkResolvedAction, { row, onResult: setResult })
1142
1279
  ] }),
1143
- row && isChargeRow(row) && /* @__PURE__ */ jsxRuntime.jsx(ChargeActions, { row, listAt, onResult: setResult }, `${row.payment_collection_id}:${row.medusa_payment_session_id ?? row.acceptance_id ?? ""}:${row.prior_intent_id ?? ""}`),
1280
+ row && isChargeRow(row) && /* @__PURE__ */ jsxRuntime.jsx(ChargeActions, { row, listAt, onResult: setResult }, `${row.payment_collection_id}:${row.medusa_payment_session_id ?? row.acceptance_id ?? ""}:${row.prior_intent_id ?? ""}:${row.watch_id ?? ""}`),
1144
1281
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-4", children: [
1145
1282
  /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h3", children: "Or do it by hand" }),
1146
1283
  steps.map((step) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
@@ -1165,7 +1302,7 @@ const HeldPaymentsPage = () => {
1165
1302
  const count = data?.count ?? 0;
1166
1303
  const remainder = count % HELD_PAGE_SIZE;
1167
1304
  const pageCount = Math.max(1, (count - remainder) / HELD_PAGE_SIZE + (remainder > 0 ? 1 : 0));
1168
- const keyOf = (row) => `${row.payment_collection_id}:${row.vp_session_id ?? "unreadable"}:${row.source}:${row.payment_id ?? ""}:${row.medusa_payment_session_id ?? ""}:${row.acceptance_id ?? ""}:${row.prior_intent_id ?? ""}`;
1305
+ const keyOf = (row) => `${row.payment_collection_id}:${row.vp_session_id ?? "unreadable"}:${row.source}:${row.payment_id ?? ""}:${row.medusa_payment_session_id ?? ""}:${row.acceptance_id ?? ""}:${row.prior_intent_id ?? ""}${row.subscription_id !== void 0 ? `:subscription:${row.vp_payment_intent_id ?? ""}` : ""}${row.watch_id ? `:watch:${row.watch_id}` : ""}`;
1169
1306
  const open = opened ? data?.holds.find((r) => keyOf(r) === keyOf(opened)) ?? opened : null;
1170
1307
  const onRowKey = (row) => (e) => {
1171
1308
  if (e.key === "Enter" || e.key === " ") {
@@ -1222,7 +1359,10 @@ const HeldPaymentsPage = () => {
1222
1359
  onClick: () => setOpened(row),
1223
1360
  onKeyDown: onRowKey(row),
1224
1361
  children: [
1225
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ReasonBadge, { reason: row.reason }) }),
1362
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1363
+ /* @__PURE__ */ jsxRuntime.jsx(ReasonBadge, { reason: row.reason }),
1364
+ row.subscription_id && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "txt-compact-small font-mono text-ui-fg-subtle", "data-testid": "held-list-subscription", children: row.subscription_id })
1365
+ ] }) }),
1226
1366
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(RowStatus, { status: row.status }) }),
1227
1367
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Tooltip, { content: row.held_at ? new Date(row.held_at).toLocaleString() : "Unknown", children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: "txt-compact-small whitespace-nowrap", children: since(row.held_at) }) }) }),
1228
1368
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.vp_session_id, label: "Vonpay session id" }) }),
@@ -1350,6 +1490,63 @@ function rulesSummary(rules) {
1350
1490
  for (const [c, n] of Object.entries(rules.max_paid ?? {})) parts.push(`paid ≤ ${n} ${c.toUpperCase()}`);
1351
1491
  return parts.length ? parts.join(" · ") : OFFERS_COPY.rulesEveryone;
1352
1492
  }
1493
+ const REPORT_COPY = {
1494
+ tabOffers: "Offers",
1495
+ tabResults: "Results",
1496
+ intro: "How shoppers answered each offer. An order counts as shown when the offer was offered to it: on the shopper's screen, or answered. Answers and money count when the offer has ended, about 2 hours after the order.",
1497
+ from: "From",
1498
+ to: "To",
1499
+ live: "Live payments",
1500
+ test: "Test payments",
1501
+ offer: "Offer",
1502
+ product: "Product",
1503
+ shown: "Shown",
1504
+ added: "Added",
1505
+ noThanks: "No thanks",
1506
+ declined: "Didn't go through",
1507
+ takeRate: "Take rate",
1508
+ charged: "Charged",
1509
+ refunded: "Refunded",
1510
+ net: "Net",
1511
+ total: "All offers",
1512
+ removed: "(removed)",
1513
+ fromConfig: "From medusa-config",
1514
+ downsell: (product) => `If they say No thanks: ${product}`,
1515
+ shownNote: "Shown counts the orders the offer was offered to, once per order (and once more for its downsell): the offer was on the shopper's screen, or the shopper answered it. It is not a count of page views.",
1516
+ takeRateNote: "Take rate is added ÷ shown.",
1517
+ declinedNote: "Didn't go through: the payment was declined or not finished, or the item could not be added.",
1518
+ chargedNote: "Charged is what shoppers paid for the offers, tax included. Refunded is what was refunded on those payments in Medusa since, by you or automatically, and never more than was charged. Refunds made outside Medusa, for example in the Vonpay dashboard, are not counted. Net is charged minus refunded.",
1519
+ datesNote: "Each row counts on the date of its own moment: a view when the offer was shown, an answer when the shopper gave it, money when it was charged. A refund counts on the date of the sale it refunds, not the day it was made. Near the start or end of your dates, a view and its answer can fall on different sides.",
1520
+ heldNote: "A payment on the Held payments page counts as it stood when it was held. For example, a late charge whose refund failed usually shows as charged (if it was held within moments of the charge, it may not show while held). If someone closes it there, it is left out of Results.",
1521
+ viewsMissing: 'Some added items have no view counted, so their take rate is not shown. Your storefront may not count views yet: see "Offer results" in the vonpay-medusa README.',
1522
+ empty: "No results for these dates yet.",
1523
+ loadFailed: "The results could not be loaded. Try again.",
1524
+ badRange: "The first date must be on or before the second, and at most 400 days apart.",
1525
+ none: "—"
1526
+ };
1527
+ function moneyText(currency, amount, minor, locale) {
1528
+ if (amount === null) return `${minor} ${currency}`;
1529
+ const digits = amount.replace(/^-/, "");
1530
+ const decimals = digits.includes(".") ? digits.length - digits.indexOf(".") - 1 : 0;
1531
+ try {
1532
+ return new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals }).format(amount);
1533
+ } catch {
1534
+ return `${amount} ${currency}`;
1535
+ }
1536
+ }
1537
+ function moneyColumn(money, which, locale) {
1538
+ if (money.length === 0) return REPORT_COPY.none;
1539
+ return money.map((m) => moneyText(m.currency, m[which], m[`${which}_minor`], locale)).join(" · ");
1540
+ }
1541
+ function takeRateText(c, locale) {
1542
+ if (c.shown <= 0 || c.added > c.shown) return null;
1543
+ return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }).format(c.added / c.shown);
1544
+ }
1545
+ function reportLineLabel(l) {
1546
+ const product = productLabel(l);
1547
+ const offer = l.offer_id === null ? REPORT_COPY.fromConfig : `${l.name ?? l.offer_id}${l.removed ? ` ${REPORT_COPY.removed}` : ""}`;
1548
+ return { offer, product: l.step === "downsell" ? REPORT_COPY.downsell(product) : product };
1549
+ }
1353
1550
  function OfferDownsellDrawer({ offer, open, onOpenChange, onSave, busy }) {
1354
1551
  const [variant, setVariant] = react.useState(offer.downsell_variant_id ?? "");
1355
1552
  const [problem, setProblem] = react.useState(null);
@@ -1381,6 +1578,114 @@ function OfferDownsellDrawer({ offer, open, onOpenChange, onSave, busy }) {
1381
1578
  ] })
1382
1579
  ] }) });
1383
1580
  }
1581
+ const KEY = "vonpay-offers";
1582
+ function useOffers() {
1583
+ return reactQuery.useQuery({ queryKey: [KEY], queryFn: () => sdk.client.fetch("/admin/vonpay/offers") });
1584
+ }
1585
+ function useOfferReport(q) {
1586
+ return reactQuery.useQuery({
1587
+ queryKey: [KEY, "report", q],
1588
+ enabled: q !== null,
1589
+ queryFn: () => sdk.client.fetch("/admin/vonpay/offers/report", { query: { from: q.from, to: q.to, ...q.mode ? { mode: q.mode } : {} } })
1590
+ });
1591
+ }
1592
+ function useWrite(send) {
1593
+ const qc = reactQuery.useQueryClient();
1594
+ return reactQuery.useMutation({ mutationFn: send, retry: false, onSettled: () => qc.invalidateQueries({ queryKey: [KEY] }) });
1595
+ }
1596
+ const useCreateOffer = () => useWrite((v) => sdk.client.fetch("/admin/vonpay/offers", { method: "POST", body: v }));
1597
+ const useUpdateOffer = () => useWrite((v) => {
1598
+ const { id, ...body } = v;
1599
+ return sdk.client.fetch(`/admin/vonpay/offers/${encodeURIComponent(id)}`, { method: "POST", body });
1600
+ });
1601
+ const useDeleteOffer = () => useWrite((v) => sdk.client.fetch(`/admin/vonpay/offers/${encodeURIComponent(v.id)}`, { method: "DELETE" }));
1602
+ const useReorderOffers = () => useWrite((v) => sdk.client.fetch("/admin/vonpay/offers/reorder", { method: "POST", body: v }));
1603
+ const useImportOffers = () => useWrite(() => sdk.client.fetch("/admin/vonpay/offers/import", { method: "POST", body: {} }));
1604
+ const MAX_SPAN_DAYS = 400;
1605
+ const DAY_MS = 24 * 60 * 6e4;
1606
+ const pad = (n) => String(n).padStart(2, "0");
1607
+ const dayText = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
1608
+ function localMidnight(day, addDays = 0) {
1609
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
1610
+ if (!m) return null;
1611
+ const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + addDays);
1612
+ return Number.isNaN(d.getTime()) ? null : d;
1613
+ }
1614
+ function rangeOf(fromDay, toDay) {
1615
+ const from = localMidnight(fromDay);
1616
+ const to = localMidnight(toDay, 1);
1617
+ if (!from || !to || !(from.getTime() < to.getTime()) || to.getTime() - from.getTime() > MAX_SPAN_DAYS * DAY_MS) return null;
1618
+ return { from: from.toISOString(), to: to.toISOString() };
1619
+ }
1620
+ function CountCells({ c }) {
1621
+ const rate = takeRateText(c);
1622
+ return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1623
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: c.shown }),
1624
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: c.added }),
1625
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: c.no_thanks }),
1626
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: c.declined }),
1627
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: rate ?? REPORT_COPY.none }),
1628
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: moneyColumn(c.money, "charged") }),
1629
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: moneyColumn(c.money, "refunded") }),
1630
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "text-right", children: moneyColumn(c.money, "net") })
1631
+ ] });
1632
+ }
1633
+ function OfferReport({ today = /* @__PURE__ */ new Date() }) {
1634
+ const [fromDay, setFromDay] = react.useState(() => dayText(new Date(today.getFullYear(), today.getMonth(), today.getDate() - 29)));
1635
+ const [toDay, setToDay] = react.useState(() => dayText(today));
1636
+ const [mode, setMode] = react.useState(void 0);
1637
+ const range = rangeOf(fromDay, toDay);
1638
+ const { data, isLoading, isError } = useOfferReport(range ? { ...range, mode } : null);
1639
+ const shownMode = mode ?? data?.mode;
1640
+ const viewsMissing = (data?.lines ?? []).some((l) => l.added > l.shown);
1641
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-4 px-6 py-4", "data-testid": "vonpay-offer-report", children: [
1642
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: REPORT_COPY.intro }),
1643
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-end gap-3", children: [
1644
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1645
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-report-from", children: REPORT_COPY.from }),
1646
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-report-from", type: "date", value: fromDay, onChange: (e) => setFromDay(e.target.value) })
1647
+ ] }),
1648
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1649
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-report-to", children: REPORT_COPY.to }),
1650
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-report-to", type: "date", value: toDay, onChange: (e) => setToDay(e.target.value) })
1651
+ ] }),
1652
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex gap-x-2", role: "group", children: ["live", "test"].map((m) => /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: shownMode === m ? "primary" : "secondary", "aria-pressed": shownMode === m, onClick: () => setMode(m), children: m === "live" ? REPORT_COPY.live : REPORT_COPY.test }, m)) })
1653
+ ] }),
1654
+ !range ? /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", "data-testid": "vonpay-report-bad-range", children: REPORT_COPY.badRange }) : isLoading ? /* @__PURE__ */ jsxRuntime.jsx(ui.Skeleton, { className: "h-8 w-full" }) : isError || !data ? /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-report-error", children: REPORT_COPY.loadFailed }) : data.lines.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { "data-testid": "vonpay-report-empty", children: REPORT_COPY.empty }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1655
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table, { children: [
1656
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { children: [
1657
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: REPORT_COPY.offer }),
1658
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: REPORT_COPY.product }),
1659
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.shown }),
1660
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.added }),
1661
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.noThanks }),
1662
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.declined }),
1663
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.takeRate }),
1664
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.charged }),
1665
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.refunded }),
1666
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { className: "text-right", children: REPORT_COPY.net })
1667
+ ] }) }),
1668
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Body, { children: [
1669
+ data.lines.map((l) => {
1670
+ const label = reportLineLabel(l);
1671
+ return /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { "data-testid": "vonpay-report-line", children: [
1672
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: l.step === "downsell" ? "" : label.offer }),
1673
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: label.product }),
1674
+ /* @__PURE__ */ jsxRuntime.jsx(CountCells, { c: l })
1675
+ ] }, `${l.offer_id ?? ""}|${l.variant_id}|${l.step}`);
1676
+ }),
1677
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { "data-testid": "vonpay-report-total", children: [
1678
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", weight: "plus", children: REPORT_COPY.total }) }),
1679
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, {}),
1680
+ /* @__PURE__ */ jsxRuntime.jsx(CountCells, { c: data.totals })
1681
+ ] })
1682
+ ] })
1683
+ ] }) }),
1684
+ viewsMissing && /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "info", "data-testid": "vonpay-report-views-missing", children: REPORT_COPY.viewsMissing }),
1685
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "flex list-disc flex-col gap-y-1 pl-6", children: [REPORT_COPY.shownNote, REPORT_COPY.takeRateNote, REPORT_COPY.declinedNote, REPORT_COPY.chargedNote, REPORT_COPY.datesNote, REPORT_COPY.heldNote].map((n) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: n }) }, n)) })
1686
+ ] })
1687
+ ] });
1688
+ }
1384
1689
  const SOURCES = {
1385
1690
  products: { path: "/admin/products", key: "products", label: "title" },
1386
1691
  collections: { path: "/admin/collections", key: "collections", label: "title" },
@@ -1551,22 +1856,6 @@ function OfferWordingDrawer({ offer, open, onOpenChange, onSave, busy }) {
1551
1856
  ] })
1552
1857
  ] }) });
1553
1858
  }
1554
- const KEY = "vonpay-offers";
1555
- function useOffers() {
1556
- return reactQuery.useQuery({ queryKey: [KEY], queryFn: () => sdk.client.fetch("/admin/vonpay/offers") });
1557
- }
1558
- function useWrite(send) {
1559
- const qc = reactQuery.useQueryClient();
1560
- return reactQuery.useMutation({ mutationFn: send, retry: false, onSettled: () => qc.invalidateQueries({ queryKey: [KEY] }) });
1561
- }
1562
- const useCreateOffer = () => useWrite((v) => sdk.client.fetch("/admin/vonpay/offers", { method: "POST", body: v }));
1563
- const useUpdateOffer = () => useWrite((v) => {
1564
- const { id, ...body } = v;
1565
- return sdk.client.fetch(`/admin/vonpay/offers/${encodeURIComponent(id)}`, { method: "POST", body });
1566
- });
1567
- const useDeleteOffer = () => useWrite((v) => sdk.client.fetch(`/admin/vonpay/offers/${encodeURIComponent(v.id)}`, { method: "DELETE" }));
1568
- const useReorderOffers = () => useWrite((v) => sdk.client.fetch("/admin/vonpay/offers/reorder", { method: "POST", body: v }));
1569
- const useImportOffers = () => useWrite(() => sdk.client.fetch("/admin/vonpay/offers/import", { method: "POST", body: {} }));
1570
1859
  const VonpayOffersPage = () => {
1571
1860
  const { data, isLoading, isError, error: loadError } = useOffers();
1572
1861
  const create = useCreateOffer();
@@ -1616,106 +1905,113 @@ const VonpayOffersPage = () => {
1616
1905
  OFFERS_COPY.lag
1617
1906
  ] })
1618
1907
  ] }),
1619
- isLoading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Skeleton, { className: "h-8 w-full" }) }) : isError || !data ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-offers-load-error", children: errorSentence(loadError, OFFERS_COPY.loadFailed) }) }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1620
- !data.upsells_on && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: OFFERS_COPY.off }) }),
1621
- data.source === "config" && data.config.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 py-4", "data-testid": "vonpay-offers-from-config", children: [
1622
- /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "info", children: OFFERS_COPY.fromConfig }),
1623
- /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "list-disc pl-6", children: data.config.map((c) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: productLabel(c) }) }, c.variant_id)) }),
1624
- /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "secondary", disabled: busy, onClick: () => void run(() => importConfig.mutateAsync(void 0)), children: OFFERS_COPY.importButton }) })
1625
- ] }),
1626
- error && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-offers-error", children: error }) }),
1627
- data.offers.length === 0 ? (data.source === "admin" || data.config.length === 0) && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: data.source === "admin" ? OFFERS_COPY.adminEmpty : OFFERS_COPY.configEmpty }) }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table, { children: [
1628
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { children: [
1629
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Offer" }),
1630
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Product" }),
1631
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.rules }),
1632
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.downsell }),
1633
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.wording }),
1634
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.shown }),
1635
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, {})
1636
- ] }) }),
1637
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Body, { children: data.offers.map((o, i) => /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { "data-testid": "vonpay-offer-row", children: [
1638
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: o.name }),
1639
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: o.product_title === null ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-error", children: OFFERS_COPY.missingVariant }) : productLabel(o) }),
1640
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.rules}: ${o.name}`, disabled: busy, onClick: () => setEditing(o), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: o.rules === "unreadable" ? "text-ui-fg-error" : void 0, children: rulesSummary(o.rules) }) }) }),
1641
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.downsell}: ${o.name}`, disabled: busy, onClick: () => setDownsellFor(o), children: o.downsell_variant_id === null ? OFFERS_COPY.downsellNone : o.downsell_product_title === null ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-ui-fg-error", children: OFFERS_COPY.downsellMissing }) : productLabel({ product_title: o.downsell_product_title, variant_title: o.downsell_variant_title, variant_id: o.downsell_variant_id }) }) }),
1642
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.wording}: ${o.name}`, disabled: busy, onClick: () => setWordingFor(o), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: `max-w-[16rem] truncate ${o.copy === "unreadable" ? "text-ui-fg-error" : ""}`, children: wordingSummary(o.copy) }) }) }),
1643
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Switch, { "aria-label": `${OFFERS_COPY.shown}: ${o.name}`, checked: o.enabled, disabled: busy, onCheckedChange: (on) => void run(() => update.mutateAsync({ id: o.id, enabled: on })) }) }),
1644
- /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-end gap-x-1", children: [
1645
- /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.up}: ${o.name}`, disabled: busy || i === 0, onClick: () => move(data.offers, i, -1), children: /* @__PURE__ */ jsxRuntime.jsx(icons.ArrowUpMini, {}) }),
1646
- /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.down}: ${o.name}`, disabled: busy || i === data.offers.length - 1, onClick: () => move(data.offers, i, 1), children: /* @__PURE__ */ jsxRuntime.jsx(icons.ArrowDownMini, {}) }),
1647
- /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.remove}: ${o.name}`, disabled: busy, onClick: () => void onRemove(o), children: /* @__PURE__ */ jsxRuntime.jsx(icons.Trash, {}) })
1648
- ] }) })
1649
- ] }, o.id)) })
1908
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Tabs, { defaultValue: "offers", className: "divide-y", children: [
1909
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-3", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Tabs.List, { children: [
1910
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Tabs.Trigger, { value: "offers", children: REPORT_COPY.tabOffers }),
1911
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Tabs.Trigger, { value: "results", children: REPORT_COPY.tabResults })
1650
1912
  ] }) }),
1651
- downsellFor && /* @__PURE__ */ jsxRuntime.jsx(
1652
- OfferDownsellDrawer,
1653
- {
1654
- offer: downsellFor,
1655
- open: downsellFor !== null,
1656
- busy,
1657
- onOpenChange: (o) => !o && setDownsellFor(null),
1658
- onSave: async (downsell) => {
1659
- try {
1660
- await update.mutateAsync({ id: downsellFor.id, downsell_variant_id: downsell });
1661
- return null;
1662
- } catch (err) {
1663
- return errorSentence(err);
1913
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Tabs.Content, { value: "results", children: /* @__PURE__ */ jsxRuntime.jsx(OfferReport, {}) }),
1914
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Tabs.Content, { value: "offers", className: "divide-y", children: isLoading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Skeleton, { className: "h-8 w-full" }) }) : isError || !data ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-offers-load-error", children: errorSentence(loadError, OFFERS_COPY.loadFailed) }) }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1915
+ !data.upsells_on && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: OFFERS_COPY.off }) }),
1916
+ data.source === "config" && data.config.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 py-4", "data-testid": "vonpay-offers-from-config", children: [
1917
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "info", children: OFFERS_COPY.fromConfig }),
1918
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "list-disc pl-6", children: data.config.map((c) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: productLabel(c) }) }, c.variant_id)) }),
1919
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "secondary", disabled: busy, onClick: () => void run(() => importConfig.mutateAsync(void 0)), children: OFFERS_COPY.importButton }) })
1920
+ ] }),
1921
+ error && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", "data-testid": "vonpay-offers-error", children: error }) }),
1922
+ data.offers.length === 0 ? (data.source === "admin" || data.config.length === 0) && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: data.source === "admin" ? OFFERS_COPY.adminEmpty : OFFERS_COPY.configEmpty }) }) : /* @__PURE__ */ jsxRuntime.jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table, { children: [
1923
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { children: [
1924
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Offer" }),
1925
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Product" }),
1926
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.rules }),
1927
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.downsell }),
1928
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.wording }),
1929
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: OFFERS_COPY.shown }),
1930
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, {})
1931
+ ] }) }),
1932
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Body, { children: data.offers.map((o, i) => /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { "data-testid": "vonpay-offer-row", children: [
1933
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: o.name }),
1934
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: o.product_title === null ? /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-error", children: OFFERS_COPY.missingVariant }) : productLabel(o) }),
1935
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.rules}: ${o.name}`, disabled: busy, onClick: () => setEditing(o), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: o.rules === "unreadable" ? "text-ui-fg-error" : void 0, children: rulesSummary(o.rules) }) }) }),
1936
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.downsell}: ${o.name}`, disabled: busy, onClick: () => setDownsellFor(o), children: o.downsell_variant_id === null ? OFFERS_COPY.downsellNone : o.downsell_product_title === null ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-ui-fg-error", children: OFFERS_COPY.downsellMissing }) : productLabel({ product_title: o.downsell_product_title, variant_title: o.downsell_variant_title, variant_id: o.downsell_variant_id }) }) }),
1937
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.wording}: ${o.name}`, disabled: busy, onClick: () => setWordingFor(o), children: /* @__PURE__ */ jsxRuntime.jsx("span", { className: `max-w-[16rem] truncate ${o.copy === "unreadable" ? "text-ui-fg-error" : ""}`, children: wordingSummary(o.copy) }) }) }),
1938
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Switch, { "aria-label": `${OFFERS_COPY.shown}: ${o.name}`, checked: o.enabled, disabled: busy, onCheckedChange: (on) => void run(() => update.mutateAsync({ id: o.id, enabled: on })) }) }),
1939
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex justify-end gap-x-1", children: [
1940
+ /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.up}: ${o.name}`, disabled: busy || i === 0, onClick: () => move(data.offers, i, -1), children: /* @__PURE__ */ jsxRuntime.jsx(icons.ArrowUpMini, {}) }),
1941
+ /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.down}: ${o.name}`, disabled: busy || i === data.offers.length - 1, onClick: () => move(data.offers, i, 1), children: /* @__PURE__ */ jsxRuntime.jsx(icons.ArrowDownMini, {}) }),
1942
+ /* @__PURE__ */ jsxRuntime.jsx(ui.IconButton, { size: "small", variant: "transparent", "aria-label": `${OFFERS_COPY.remove}: ${o.name}`, disabled: busy, onClick: () => void onRemove(o), children: /* @__PURE__ */ jsxRuntime.jsx(icons.Trash, {}) })
1943
+ ] }) })
1944
+ ] }, o.id)) })
1945
+ ] }) }),
1946
+ downsellFor && /* @__PURE__ */ jsxRuntime.jsx(
1947
+ OfferDownsellDrawer,
1948
+ {
1949
+ offer: downsellFor,
1950
+ open: downsellFor !== null,
1951
+ busy,
1952
+ onOpenChange: (o) => !o && setDownsellFor(null),
1953
+ onSave: async (downsell) => {
1954
+ try {
1955
+ await update.mutateAsync({ id: downsellFor.id, downsell_variant_id: downsell });
1956
+ return null;
1957
+ } catch (err) {
1958
+ return errorSentence(err);
1959
+ }
1664
1960
  }
1665
- }
1666
- },
1667
- downsellFor.id
1668
- ),
1669
- wordingFor && /* @__PURE__ */ jsxRuntime.jsx(
1670
- OfferWordingDrawer,
1671
- {
1672
- offer: wordingFor,
1673
- open: wordingFor !== null,
1674
- busy,
1675
- onOpenChange: (o) => !o && setWordingFor(null),
1676
- onSave: async (copy) => {
1677
- try {
1678
- await update.mutateAsync({ id: wordingFor.id, copy });
1679
- return null;
1680
- } catch (err) {
1681
- return errorSentence(err);
1961
+ },
1962
+ downsellFor.id
1963
+ ),
1964
+ wordingFor && /* @__PURE__ */ jsxRuntime.jsx(
1965
+ OfferWordingDrawer,
1966
+ {
1967
+ offer: wordingFor,
1968
+ open: wordingFor !== null,
1969
+ busy,
1970
+ onOpenChange: (o) => !o && setWordingFor(null),
1971
+ onSave: async (copy) => {
1972
+ try {
1973
+ await update.mutateAsync({ id: wordingFor.id, copy });
1974
+ return null;
1975
+ } catch (err) {
1976
+ return errorSentence(err);
1977
+ }
1682
1978
  }
1683
- }
1684
- },
1685
- wordingFor.id
1686
- ),
1687
- editing && /* @__PURE__ */ jsxRuntime.jsx(
1688
- OfferRulesDrawer,
1689
- {
1690
- offer: editing,
1691
- open: editing !== null,
1692
- busy,
1693
- onOpenChange: (o) => !o && setEditing(null),
1694
- onSave: async (rules) => {
1695
- try {
1696
- await update.mutateAsync({ id: editing.id, rules });
1697
- return null;
1698
- } catch (err) {
1699
- return errorSentence(err);
1979
+ },
1980
+ wordingFor.id
1981
+ ),
1982
+ editing && /* @__PURE__ */ jsxRuntime.jsx(
1983
+ OfferRulesDrawer,
1984
+ {
1985
+ offer: editing,
1986
+ open: editing !== null,
1987
+ busy,
1988
+ onOpenChange: (o) => !o && setEditing(null),
1989
+ onSave: async (rules) => {
1990
+ try {
1991
+ await update.mutateAsync({ id: editing.id, rules });
1992
+ return null;
1993
+ } catch (err) {
1994
+ return errorSentence(err);
1995
+ }
1700
1996
  }
1701
- }
1702
- },
1703
- editing.id
1704
- ),
1705
- /* @__PURE__ */ jsxRuntime.jsxs("form", { className: "flex flex-col gap-y-3 px-6 py-4", onSubmit: (e) => void onAdd(e), children: [
1706
- /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h2", children: OFFERS_COPY.add }),
1707
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [
1708
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1709
- /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-offer-name", children: OFFERS_COPY.name }),
1710
- /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-offer-name", value: name, maxLength: 80, onChange: (e) => setName(e.target.value) })
1997
+ },
1998
+ editing.id
1999
+ ),
2000
+ /* @__PURE__ */ jsxRuntime.jsxs("form", { className: "flex flex-col gap-y-3 px-6 py-4", onSubmit: (e) => void onAdd(e), children: [
2001
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h2", children: OFFERS_COPY.add }),
2002
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "grid grid-cols-1 gap-3 md:grid-cols-2", children: [
2003
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
2004
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-offer-name", children: OFFERS_COPY.name }),
2005
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-offer-name", value: name, maxLength: 80, onChange: (e) => setName(e.target.value) })
2006
+ ] }),
2007
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
2008
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-offer-variant", children: OFFERS_COPY.variant }),
2009
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-offer-variant", value: variantId, onChange: (e) => setVariantId(e.target.value) })
2010
+ ] })
1711
2011
  ] }),
1712
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-1", children: [
1713
- /* @__PURE__ */ jsxRuntime.jsx(ui.Label, { htmlFor: "vonpay-offer-variant", children: OFFERS_COPY.variant }),
1714
- /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: "vonpay-offer-variant", value: variantId, onChange: (e) => setVariantId(e.target.value) })
1715
- ] })
1716
- ] }),
1717
- /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", type: "submit", disabled: busy || !name.trim() || !variantId.trim(), children: OFFERS_COPY.add }) })
1718
- ] })
2012
+ /* @__PURE__ */ jsxRuntime.jsx("div", { children: /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", type: "submit", disabled: busy || !name.trim() || !variantId.trim(), children: OFFERS_COPY.add }) })
2013
+ ] })
2014
+ ] }) })
1719
2015
  ] })
1720
2016
  ] });
1721
2017
  };
@@ -1735,6 +2031,8 @@ const eventLabel = (type) => EVENT_LABEL[type] ?? "Other";
1735
2031
  const amountLabel = (major, currency) => major && currency ? `${major} ${currency}` : "—";
1736
2032
  const NOT_ENABLED_NOTE = `Vonpay is not running this store's subscriptions. To let it, set the payment provider's subscriptions option to "vonpay", register the plugin's subscriptions module in medusa-config, run npx medusa db:migrate and restart — the setup check confirms each step.`;
1737
2033
  const ORDERS_NOT_YET_NOTE = "Renewal orders are not created from these notifications yet: this list shows what Vonpay reported, so nothing here changes an order.";
2034
+ const CANCEL_IS_SEPARATE_NOTE = "Cancelling or refunding the order that started a subscription does not end the subscription: it keeps renewing until someone cancels it in your Vonpay dashboard.";
2035
+ const subscriptionOrderPath = (orderId) => `/orders/${encodeURIComponent(orderId)}`;
1738
2036
  const SUBSCRIPTION_PAGE_SIZE = 20;
1739
2037
  function useSubscriptionEvents(page) {
1740
2038
  return reactQuery.useQuery({
@@ -1757,12 +2055,16 @@ const VonpaySubscriptionsPage = () => {
1757
2055
  /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: "Notifications from Vonpay's subscription billing, newest first." })
1758
2056
  ] }),
1759
2057
  isLoading ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Skeleton, { className: "h-8 w-full" }) }) : isError ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", children: "The list could not be loaded. Reload the page to try again." }) }) : !data?.enabled ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "info", children: NOT_ENABLED_NOTE }) }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1760
- /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: ORDERS_NOT_YET_NOTE }) }),
2058
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "px-6 py-4", children: [
2059
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: ORDERS_NOT_YET_NOTE }),
2060
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", "data-testid": "vonpay-subscriptions-cancel-note", children: CANCEL_IS_SEPARATE_NOTE })
2061
+ ] }),
1761
2062
  data.events.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { children: "No subscription notifications yet." }) }) : /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1762
2063
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: "overflow-x-auto", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table, { children: [
1763
2064
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Header, { children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Table.Row, { children: [
1764
2065
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "What happened" }),
1765
2066
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Subscription" }),
2067
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Started by order" }),
1766
2068
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Period" }),
1767
2069
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Amount" }),
1768
2070
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.HeaderCell, { children: "Next bill" }),
@@ -1774,6 +2076,7 @@ const VonpaySubscriptionsPage = () => {
1774
2076
  e.mode === "test" ? " (test)" : ""
1775
2077
  ] }),
1776
2078
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "font-mono text-xs", children: e.subscription_id }),
2079
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { className: "font-mono text-xs", "data-testid": "vonpay-subscription-order", children: e.medusa_order_id ? /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: subscriptionOrderPath(e.medusa_order_id), children: e.medusa_order_id }) : "—" }),
1777
2080
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: e.billing_cycle ?? "—" }),
1778
2081
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: amountLabel(e.amount_major, e.currency) }),
1779
2082
  /* @__PURE__ */ jsxRuntime.jsx(ui.Table.Cell, { children: when(e.next_bill_at) }),
@@ -1818,6 +2121,11 @@ const widgetModule = { widgets: [
1818
2121
  Component: UpdateBanner,
1819
2122
  zone: ["order.list.before"],
1820
2123
  widgetId: "Widget-5ad3"
2124
+ },
2125
+ {
2126
+ Component: VonpayVariantSubscription,
2127
+ zone: ["product_variant.details.after"],
2128
+ widgetId: "Widget-671a"
1821
2129
  }
1822
2130
  ] };
1823
2131
  const routeModule = {