vonpay-medusa 1.1.1 → 1.2.1

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 (57) hide show
  1. package/.medusa/server/src/admin/index.js +286 -81
  2. package/.medusa/server/src/admin/index.mjs +287 -82
  3. package/.medusa/server/src/api/admin/vonpay/holds/resolve/route.js +6 -0
  4. package/.medusa/server/src/api/admin/vonpay/holds/route.js +2 -1
  5. package/.medusa/server/src/api/admin/vonpay/setup-check/move-to-held/route.js +14 -0
  6. package/.medusa/server/src/api/middlewares.js +1 -0
  7. package/.medusa/server/src/lib/errors.js +9 -1
  8. package/.medusa/server/src/lib/held-charge-copy.js +176 -0
  9. package/.medusa/server/src/lib/money.js +4 -0
  10. package/.medusa/server/src/modules/vonpay-jobs/index.js +11 -0
  11. package/.medusa/server/src/modules/vonpay-jobs/key.js +4 -0
  12. package/.medusa/server/src/modules/vonpay-jobs/migrations/Migration20261105120000.js +15 -0
  13. package/.medusa/server/src/modules/vonpay-jobs/models/job-beat.js +20 -0
  14. package/.medusa/server/src/modules/vonpay-jobs/service.js +7 -0
  15. package/.medusa/server/src/providers/vonpay/admin-holds.js +57 -38
  16. package/.medusa/server/src/providers/vonpay/charge-hold.js +124 -0
  17. package/.medusa/server/src/providers/vonpay/deadline.js +26 -0
  18. package/.medusa/server/src/providers/vonpay/descriptor.js +4 -4
  19. package/.medusa/server/src/providers/vonpay/held-list.js +168 -26
  20. package/.medusa/server/src/providers/vonpay/job-beat.js +57 -0
  21. package/.medusa/server/src/providers/vonpay/move-to-held.js +95 -0
  22. package/.medusa/server/src/providers/vonpay/options.js +11 -1
  23. package/.medusa/server/src/providers/vonpay/reading.js +25 -2
  24. package/.medusa/server/src/providers/vonpay/recovery.js +44 -11
  25. package/.medusa/server/src/providers/vonpay/refund-after-order.js +35 -0
  26. package/.medusa/server/src/providers/vonpay/renewal-check.js +435 -0
  27. package/.medusa/server/src/providers/vonpay/renewal-recovery.js +425 -25
  28. package/.medusa/server/src/providers/vonpay/renewal.js +542 -39
  29. package/.medusa/server/src/providers/vonpay/service.js +604 -99
  30. package/.medusa/server/src/providers/vonpay/session-data.js +248 -9
  31. package/.medusa/server/src/providers/vonpay/setup-check.js +514 -5
  32. package/.medusa/server/src/providers/vonpay/upsell-accept.js +99 -35
  33. package/.medusa/server/src/scripts/vonpay-check.js +23 -1
  34. package/.medusa/server/src/subscribers/vonpay-order-placed.js +2 -0
  35. package/README.md +249 -9
  36. package/bin/vonpay-medusa-check.cjs +23 -3
  37. package/bin/vonpay-medusa.cjs +241 -8
  38. package/package.json +4 -3
  39. package/storefront/src/cache-tag.ts +7 -0
  40. package/storefront/src/card-form-keep.ts +13 -0
  41. package/storefront/src/cart-fields.ts +1 -0
  42. package/storefront/src/checkout-steps.ts +27 -0
  43. package/storefront/src/context.tsx +1 -1
  44. package/storefront/src/copy.ts +7 -0
  45. package/storefront/src/csp.ts +1 -1
  46. package/storefront/src/index.ts +2 -0
  47. package/storefront/src/offer-prepare.ts +3 -2
  48. package/storefront/src/outcome.ts +12 -0
  49. package/storefront/src/payment-step.ts +99 -0
  50. package/storefront/src/reload-once.ts +21 -0
  51. package/storefront/src/server.ts +49 -12
  52. package/storefront/src/shared.ts +2 -0
  53. package/storefront/src/shown-total.ts +21 -0
  54. package/storefront/src/vonpay-card-payment.tsx +204 -16
  55. package/storefront/src/vonpay-payment-view.tsx +1 -1
  56. package/storefront/src/vora-csp.json +17 -3
  57. package/storefront/src/vora.ts +11 -1
@@ -9,6 +9,47 @@ const icons = require("@medusajs/icons");
9
9
  const react = require("react");
10
10
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
11
11
  const Medusa__default = /* @__PURE__ */ _interopDefault(Medusa);
12
+ const TIME_SLOT = "{time}";
13
+ function fillTime(text, at, format) {
14
+ return at && text.includes(TIME_SLOT) ? text.split(TIME_SLOT).join(format(at)) : text;
15
+ }
16
+ 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
+ const CHARGE_REASON_LABEL = {
18
+ renewal_outcome_unknown: "Renewal charge — result unknown",
19
+ renewal_not_settled: "Renewal charge not completed",
20
+ renewal_charged_unrecorded: "Renewal charged, order unpaid",
21
+ renewal_paid_twice: "Renewal charged — order also paid another way",
22
+ renewal_buyer_mismatch: "Renewal charged to a different customer",
23
+ upsell_outcome_unknown: "Offer charge — result unknown",
24
+ upsell_not_settled: "Offer charge not completed",
25
+ upsell_paid_not_added: "Offer paid, item not added",
26
+ upsell_paid_unsettled: "Offer paid — check the order",
27
+ upsell_paid_unrecorded: "Offer paid, payment not recorded",
28
+ upsell_hold_not_released: "Offer payment held — release not confirmed",
29
+ upsell_bank_check_open: "Offer bank check still open",
30
+ upsell_refund_failed: "Late offer charge — refund failed",
31
+ upsell_refund_unclear: "Late offer charge — refund unclear",
32
+ upsell_refund_pending: "Late offer charge — refund in progress",
33
+ upsell_refunded_late: "Late offer charge refunded",
34
+ upsell_hold_unsettled: "Offer payment held — not finished yet",
35
+ upsell_take_unconfirmed: "Offer payment — collection not confirmed"
36
+ };
37
+ const CHARGE_RESOLVED_HOW_LABEL = {
38
+ closed_blocking: "Closed by a person — no more renewal charges for this order",
39
+ freed: "Freed — later renewals of this order may charge the customer",
40
+ closed_no_change: "Closed by a person — no change to money"
41
+ };
42
+ function chargeHandStep(paymentIntentId) {
43
+ return { title: "Check this order in Vonpay", body: `Find ${paymentIntentId ? `payment ${paymentIntentId}` : "any payment"} in the Vonpay dashboard by the order id, and settle it there if it needs it.` };
44
+ }
45
+ const OFFER_CLOSED_MONEY_TEXT = {
46
+ upsell_paid_not_added: "The buyer paid for this offer. Closing this row did not refund it or add the item: that was yours to settle in Vonpay or on the order.",
47
+ upsell_paid_unsettled: "The buyer paid for this offer. Closing this row did not refund it or add the item: that was yours to settle in Vonpay or on the order.",
48
+ upsell_paid_unrecorded: "The buyer paid, and the item is on the order. Closing this row did not mark that balance paid in Medusa: that was yours to do.",
49
+ upsell_refund_failed: "The buyer was charged and the item was not added. Closing this row sent no refund: that was yours to send in Vonpay.",
50
+ upsell_refund_unclear: "The buyer was charged and the item was not added. Closing this row sent no refund: that was yours to send in Vonpay."
51
+ };
52
+ const OFFER_CLOSED_MONEY_DEFAULT = "Closing this row moved no money. What Vonpay holds for this payment is as it was.";
12
53
  const REASON_COPY = {
13
54
  deleted_while_charged: {
14
55
  label: "Charged, then cart changed",
@@ -82,78 +123,7 @@ const REASON_COPY = {
82
123
  label: "Paid, order not created",
83
124
  what: "The buyer paid, but Medusa did not create the order, even after the plugin retried it automatically. Check the cart and the payment in Vonpay, then complete the order with the buyer or refund it."
84
125
  },
85
- renewal_outcome_unknown: {
86
- label: "Renewal charge — result unknown",
87
- what: "A subscription renewal charged the customer's saved card, but Vonpay's answer could not be read, so the customer may have been charged. No further renewal charge is sent for this order. Check the payment in Vonpay before charging or refunding anything."
88
- },
89
- renewal_not_settled: {
90
- label: "Renewal charge not completed",
91
- what: "Vonpay answered the subscription renewal charge, but the payment had not completed. No further renewal charge is sent for this order. Check the payment in Vonpay."
92
- },
93
- renewal_charged_unrecorded: {
94
- label: "Renewal charged, order unpaid",
95
- what: "Vonpay charged the customer's saved card for this subscription renewal, but Medusa's records do not show the payment, even after the plugin retried it automatically (or Medusa recorded it and then cancelled it). The customer has paid; the order shows unpaid. No further renewal charge is sent for this order. Find the payment in Vonpay by the order id, then mark the order paid in Medusa — do not charge again."
96
- },
97
- renewal_paid_twice: {
98
- label: "Renewal charged — order also paid another way",
99
- what: "Vonpay charged the customer's saved card for this subscription renewal, and the order was also marked paid another way. The customer may have paid twice. Check the payment in Vonpay and the order in Medusa, then refund the extra payment. No further renewal charge is sent for this order."
100
- },
101
- renewal_buyer_mismatch: {
102
- label: "Renewal charged to a different customer",
103
- what: "Vonpay recorded the subscription renewal charge against a different customer than the order's. No further renewal charge is sent for this order. Check the payment in Vonpay."
104
- },
105
- upsell_outcome_unknown: {
106
- label: "Offer charge — result unknown",
107
- what: "The buyer accepted an offer after their order and the plugin charged their card for it, but Vonpay's answer could not be read, so the buyer may have been charged. The item was NOT added to the order, and the plugin will not try that offer again. Check the payment in Vonpay by the order id: if it was charged, add the item to the order (or refund it in Vonpay); if not, cancel the open order edit in Medusa."
108
- },
109
- upsell_not_settled: {
110
- label: "Offer charge not completed",
111
- what: "The buyer accepted an offer after their order, and Vonpay answered the charge, but the payment had not completed. The item was NOT added to the order. Check the payment in Vonpay by the order id, then cancel the open order edit in Medusa if nothing was charged."
112
- },
113
- upsell_paid_not_added: {
114
- label: "Offer paid, item not added",
115
- what: "The buyer accepted an offer after their order and paid for it, but the item was NOT added to the order, even after the plugin retried. The buyer has paid. Either add the item to the order in Medusa, or refund the charge in Vonpay — not both, and do not charge again."
116
- },
117
- upsell_paid_unsettled: {
118
- label: "Offer paid — check the order",
119
- what: "The buyer accepted an offer after their order and paid for it, but the plugin could not finish recording it. Open the order first. If the item is on it, mark that balance paid in Medusa (do NOT refund — the item ships). If the item is not on it, either add it or refund the charge in Vonpay — not both. Never charge again."
120
- },
121
- upsell_paid_unrecorded: {
122
- label: "Offer paid, payment not recorded",
123
- what: "The buyer accepted an offer after their order and paid for it, and the item IS on the order, but Medusa did not record the payment, even after the plugin retried — so the order shows an unpaid balance. The buyer has paid. Find the payment in Vonpay by the order id and mark that balance paid in Medusa. Do NOT refund it (the item ships) and do not charge again."
124
- },
125
- upsell_hold_not_released: {
126
- label: "Offer payment held — release not confirmed",
127
- what: "The buyer approved an offer after their order with Apple Pay or Google Pay, which only held the money. The item could not be added, so the plugin released the hold, but Vonpay has not confirmed the release. Nothing was taken and the item was NOT added. The plugin keeps releasing it on every run. If this row stays, find the payment in Vonpay by the order id and void it there. Do not take it."
128
- },
129
- upsell_bank_check_open: {
130
- label: "Offer bank check still open",
131
- what: "The offer ended while the buyer's bank check was still open, so nothing was added and the order change was undone. If the bank approves it later, the plugin refunds it automatically. Nothing to do unless this stays for a few days; then check the payment in Vonpay by the order id."
132
- },
133
- upsell_refund_failed: {
134
- label: "Late offer charge — refund failed",
135
- what: "The buyer was charged for an offer whose item was NOT added to the order — the bank approved it after the offer had ended, the item could not be added, or an earlier declined attempt was approved after all — so the plugin tried to refund that charge in full, but the refund did not go through (refused, or not settled within 48 hours). Refund the payment in Vonpay by the order id. Do not add the item."
136
- },
137
- upsell_refund_unclear: {
138
- label: "Late offer charge — refund unclear",
139
- what: "The buyer was charged for an offer whose item was NOT added to the order, and the plugin's automatic refund got an unclear answer: Vonpay reported an error, then recorded that refund as failed, but the money may have gone back anyway. The plugin will not send another refund. First check the payment in Vonpay by the order id: refund it there ONLY if no refund went through. Do not add the item."
140
- },
141
- upsell_refund_pending: {
142
- label: "Late offer charge — refund in progress",
143
- what: "The buyer's bank approved an offer after the offer had ended, so nothing was added and the plugin is refunding the charge in full. It keeps retrying. If this row stays for a day, refund the payment in Vonpay by the order id."
144
- },
145
- upsell_refunded_late: {
146
- label: "Late offer charge refunded",
147
- what: "The buyer's bank approved an offer after the offer had ended, so nothing was added and the plugin refunded the charge in full. Nothing to do."
148
- },
149
- upsell_hold_unsettled: {
150
- label: "Offer payment held — not finished yet",
151
- what: "The buyer approved an offer after their order with Apple Pay or Google Pay, which only held the money, and the plugin has not finished it yet. Nothing was taken. The plugin keeps working on it: it adds the item and takes the money, or releases the hold if the item cannot be added. Do not take or void it yourself unless this row stays for more than a day; then check the payment in Vonpay by the order id."
152
- },
153
- upsell_take_unconfirmed: {
154
- label: "Offer payment — collection not confirmed",
155
- what: "The buyer approved an offer after their order with Apple Pay or Google Pay, and the plugin asked Vonpay to take the held money, but Vonpay has not confirmed whether it was taken. The plugin re-checks it on every run: if it was taken, the plugin adds the item; if it was released, it closes the open order edit. Check the payment in Vonpay by the order id. Do not take it again."
156
- },
126
+ ...Object.fromEntries(Object.entries(CHARGE_REASON_LABEL).map(([reason, label]) => [reason, { label }])),
157
127
  cancel_refund_missing: {
158
128
  label: "Cancelled, refund failed",
159
129
  what: "The order was cancelled, but Medusa's refund to the buyer did not go through. The buyer may still be charged."
@@ -207,6 +177,7 @@ const RESOLVED_HOW_LABEL = {
207
177
  refunded_earlier: "Already refunded with this payment's key (for example from the command line)",
208
178
  nothing_left: "Vonpay showed nothing left to refund — confirm in the Vonpay dashboard that the buyer got it back",
209
179
  marked: "Marked resolved",
180
+ ...CHARGE_RESOLVED_HOW_LABEL,
210
181
  auto_closed: "Closed automatically — Vonpay confirmed nothing was charged and the payment can no longer be used",
211
182
  paid_after_auto_close: "Re-opened automatically — money arrived after the automatic close",
212
183
  paid_after_resolution: "Re-opened automatically — Vonpay reported this payment as paid after it was marked resolved",
@@ -250,6 +221,9 @@ function isConfirmedRefund(row) {
250
221
  function canRefund(row) {
251
222
  return row.vp_session_id !== null && !isConfirmedRefund(row) && !isAutoClosed(row) && !isUnrecognisedRecord(row) && REFUNDABLE_REASONS.includes(row.reason);
252
223
  }
224
+ function isChargeRow(row) {
225
+ return row.source === "renewal" || row.source === "upsell";
226
+ }
253
227
  function canRelease(row) {
254
228
  return row.source === "collection" && row.vp_session_id !== null && row.status === "open" && WALLET_REASONS.includes(row.reason);
255
229
  }
@@ -257,9 +231,20 @@ function canComplete(row) {
257
231
  return row.source === "collection" && row.vp_session_id !== null && row.status === "open" && row.reason === "wallet_held_no_order";
258
232
  }
259
233
  function canMarkResolved(row) {
234
+ if (isChargeRow(row)) return false;
260
235
  return row.vp_session_id !== null && row.status !== "resolved" && !isUnrecognisedRecord(row) && !WALLET_REASONS.includes(row.reason) && !TAKE_REASONS.includes(row.reason);
261
236
  }
262
237
  function locatorFor(row) {
238
+ if (isChargeRow(row)) {
239
+ return {
240
+ source: row.source,
241
+ payment_collection_id: row.payment_collection_id,
242
+ ...row.order_id ? { order_id: row.order_id } : {},
243
+ ...row.medusa_payment_session_id ? { medusa_payment_session_id: row.medusa_payment_session_id } : {},
244
+ ...row.acceptance_id ? { acceptance_id: row.acceptance_id } : {},
245
+ ...row.prior_intent_id ? { prior_intent_id: row.prior_intent_id } : {}
246
+ };
247
+ }
263
248
  return {
264
249
  source: row.source,
265
250
  vp_session_id: row.vp_session_id ?? "",
@@ -269,6 +254,9 @@ function locatorFor(row) {
269
254
  };
270
255
  }
271
256
  function resolutionSteps(row) {
257
+ if (isChargeRow(row)) {
258
+ return [chargeHandStep(row.vp_payment_intent_id)];
259
+ }
272
260
  if (!row.vp_session_id || row.reason === "unrecognised") {
273
261
  return [{ title: "Check this payment in Vonpay", body: "Find the payment in the Vonpay dashboard before refunding or fulfilling anything." }];
274
262
  }
@@ -348,6 +336,14 @@ function reconcileCopy(o) {
348
336
  function markConfirmMatches(typed, row) {
349
337
  return row.vp_session_id !== null && typed.trim() === row.vp_session_id;
350
338
  }
339
+ function closedMoneyNote(row) {
340
+ if (row.status !== "resolved" || row.resolution?.how !== "closed_no_change") return null;
341
+ return OFFER_CLOSED_MONEY_TEXT[row.reason] ?? OFFER_CLOSED_MONEY_DEFAULT;
342
+ }
343
+ function chargedUncheckedNote(count) {
344
+ if (!(count > 0)) return null;
345
+ return `${count} paid ${count === 1 ? "renewal was" : "renewals were"} not checked against Medusa on this load, because a load checks a limited number or Medusa did not answer. One that Medusa has no record of may be missing from this list. Reload to check the next ones.`;
346
+ }
351
347
  const REFUSAL_TEXT = {
352
348
  hold_release_not_eligible: "Only a held wallet payment can be released here. Nothing was changed.",
353
349
  hold_release_payment_active: "This held payment belongs to an order. Take it or cancel it from the order. Nothing was changed.",
@@ -369,7 +365,7 @@ const REFUSAL_TEXT = {
369
365
  hold_refund_auto_closed: "This payment was closed automatically because Vonpay confirmed nothing was charged. There's nothing to refund.",
370
366
  hold_refund_not_eligible: "This held payment has no single charge that can be refunded from here. Nothing was refunded — check it in Vonpay.",
371
367
  hold_record_unrecognised: "This payment's record in the list isn't one the plugin recognises. Nothing was changed — check it in Vonpay.",
372
- refund_refused: "Vonpay refused the refund. Nothing was refunded.",
368
+ refund_refused: "Vonpay refused the refund. Nothing was refunded. If the buyer already has the money back (a dispute, or a refund made in the Vonpay dashboard), close this row with Mark resolved.",
373
369
  refund_exceeds_remaining: "Vonpay shows nothing left to refund on this payment. Nothing new was refunded.",
374
370
  hold_provider_unavailable: "The Vonpay payment provider couldn't be reached from the admin. Nothing was changed — try again.",
375
371
  hold_provider_ambiguous: "More than one Vonpay provider could own this payment. Nothing was changed — check the store's payment setup.",
@@ -377,7 +373,7 @@ const REFUSAL_TEXT = {
377
373
  hold_refund_key_conflict: "A refund with this payment's key was already made with different details (for example a partial amount from the command line). Nothing new was refunded — check it in Vonpay.",
378
374
  refund_declined: "The payment provider declined the refund. No money moved.",
379
375
  refund_not_completed: "Vonpay recorded this refund as failed. No money moved.",
380
- hold_refund_not_refundable_now: "Vonpay says nothing can be refunded on this payment right now (not settled yet, disputed, or already refunded). Nothing was refunded.",
376
+ hold_refund_not_refundable_now: "Vonpay says nothing can be refunded on this payment right now (not settled yet, disputed, or already refunded). Nothing was refunded. Once the buyer has the money back (a dispute, or a refund made in the Vonpay dashboard), close this row with Mark resolved; if it has not settled yet, try again later.",
381
377
  hold_checkout_busy: "The buyer's checkout for this payment is in progress right now. Nothing was changed — try again in a moment.",
382
378
  hold_checkout_lock_unavailable: "This payment's checkout couldn't be locked, so nothing was changed. Check the store's locking setup, or refund from the command line.",
383
379
  hold_recheck_failed: "The order couldn't be re-checked just before acting. Nothing was changed — try again.",
@@ -387,6 +383,14 @@ const REFUSAL_TEXT = {
387
383
  hold_refund_payment_active: "This payment now belongs to an order that isn't cancelled. Nothing was refunded — refund it from the order.",
388
384
  hold_resolve_wallet_held: "Use Release for a held wallet payment. It checks Vonpay first, then releases the hold, lists it for a refund, or clears it with its order. Nothing was changed.",
389
385
  hold_resolve_payment_active: "This payment now belongs to an order that isn't cancelled, so it isn't held. Nothing was changed.",
386
+ move_request_invalid: "That request was missing the payment's ids. Run the check again. Nothing was changed.",
387
+ hold_resolve_confirm_mismatch: "To mark a payment resolved, type its Vonpay session id exactly as the row shows it. Nothing was changed.",
388
+ move_not_listed: "This payment isn't one the check lists any more (it may have become an order, or already be on Held payments). Run the check again. Nothing was changed.",
389
+ move_not_paid: "Vonpay doesn't show money taken or held on this payment, so there is nothing to move. Nothing was changed.",
390
+ move_refunded: "Vonpay shows this payment refunded in full (or a full refund still in progress), so there is nothing to move. Nothing was changed.",
391
+ move_not_this_checkout: "Vonpay's record of this payment names a different checkout. Check it in the Vonpay dashboard. Nothing was changed.",
392
+ move_lock_not_shared: "The command can't move a payment on a store without a shared lock (Redis or Postgres locking). Use the Move to Held payments button in the admin instead. Nothing was changed.",
393
+ move_vonpay_unavailable: "Vonpay couldn't be reached to check this payment again. Nothing was changed — try again shortly.",
390
394
  hold_not_found: "This held payment is no longer on the list. It may already have been completed, released or refunded. Reload the list and check the order."
391
395
  };
392
396
  function outcomeCopy(o) {
@@ -456,6 +460,23 @@ function completeOutcomeCopy(o) {
456
460
  return UNKNOWN_OUTCOME;
457
461
  }
458
462
  }
463
+ function moveOutcomeCopy(o) {
464
+ switch (o.outcome) {
465
+ case "moved":
466
+ return {
467
+ variant: "success",
468
+ text: o.reason === "wallet_held_no_order" ? "Moved to Held payments. Release the held payment or complete the order there." : "Moved to Held payments. Refund it or mark it resolved there."
469
+ };
470
+ case "already_held":
471
+ return { variant: "success", text: "It is already on Held payments. Nothing was changed." };
472
+ case "ordered":
473
+ return { variant: "warning", text: "An order holds this payment now, so it was not moved. Nothing was changed." };
474
+ case "refused":
475
+ return { variant: "error", text: o.code && REFUSAL_TEXT[o.code] || "That couldn't be done. Nothing was changed." };
476
+ default:
477
+ return UNKNOWN_OUTCOME;
478
+ }
479
+ }
459
480
  function resolveOutcomeCopy(o) {
460
481
  if (o.outcome === "refused") return { variant: "error", text: o.code && REFUSAL_TEXT[o.code] || "That couldn't be done. Nothing was changed." };
461
482
  if (!o.recorded) return { variant: "warning", text: "Couldn't save that. Try again." };
@@ -466,6 +487,7 @@ const authType = typeof __AUTH_TYPE__ !== "undefined" && __AUTH_TYPE__ ? __AUTH_
466
487
  const sdk = new Medusa__default.default({ baseUrl: backendUrl, auth: { type: authType } });
467
488
  const HELD_PAGE_SIZE = 20;
468
489
  const LIST_KEY = "vonpay-held-payments";
490
+ const HELD_LIST_KEY = LIST_KEY;
469
491
  function useHeldPayments(page) {
470
492
  const status = page.status ?? "open";
471
493
  return reactQuery.useQuery({
@@ -528,6 +550,22 @@ function useMarkResolved() {
528
550
  onSettled: () => qc.invalidateQueries({ queryKey: [LIST_KEY] })
529
551
  });
530
552
  }
553
+ function useChargeAction() {
554
+ const qc = reactQuery.useQueryClient();
555
+ return reactQuery.useMutation({
556
+ mutationFn: (v) => sdk.client.fetch("/admin/vonpay/holds/resolve", {
557
+ method: "POST",
558
+ body: {
559
+ ...locatorFor(v.row),
560
+ action: v.action,
561
+ ...v.typed_order_id !== void 0 ? { confirm_order_id: v.typed_order_id.trim(), confirm_checked_in_vonpay: v.checked_in_vonpay === true } : {},
562
+ ...v.action === "close_unreadable" && v.row.record_sha256 ? { confirm_record_sha256: v.row.record_sha256 } : {}
563
+ }
564
+ }),
565
+ retry: false,
566
+ onSettled: () => qc.invalidateQueries({ queryKey: [LIST_KEY] })
567
+ });
568
+ }
531
569
  function useReconcileHold() {
532
570
  const qc = reactQuery.useQueryClient();
533
571
  return reactQuery.useMutation({
@@ -632,8 +670,42 @@ function useSetupCheck() {
632
670
  retry: false
633
671
  });
634
672
  }
673
+ function useMoveToHeld() {
674
+ const qc = reactQuery.useQueryClient();
675
+ return reactQuery.useMutation({
676
+ mutationFn: (m) => sdk.client.fetch("/admin/vonpay/setup-check/move-to-held", { method: "POST", body: { vp_session_id: m.vp_session_id, medusa_payment_session_id: m.medusa_payment_session_id } }),
677
+ retry: false,
678
+ onSettled: () => qc.invalidateQueries({ queryKey: [HELD_LIST_KEY] })
679
+ });
680
+ }
635
681
  const LABEL = { pass: "OK", warn: "Check", fail: "Fix", not_checked: "Not checked" };
636
682
  const COLOR = { pass: "green", warn: "orange", fail: "red", not_checked: "grey" };
683
+ function uniqueMoves(moves) {
684
+ const seen = /* @__PURE__ */ new Set();
685
+ return (moves ?? []).filter((m) => !seen.has(m.vp_session_id) && (seen.add(m.vp_session_id), true));
686
+ }
687
+ const MoveRow = ({ move }) => {
688
+ const mutation = useMoveToHeld();
689
+ const [result, setResult] = react.useState(null);
690
+ const done = result !== null && result.variant !== "error";
691
+ return /* @__PURE__ */ jsxRuntime.jsxs("li", { className: "flex flex-col gap-y-1 sm:flex-row sm:items-center sm:gap-x-3", children: [
692
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "font-mono", children: move.vp_session_id }),
693
+ !done && /* @__PURE__ */ jsxRuntime.jsx(
694
+ ui.Button,
695
+ {
696
+ size: "small",
697
+ variant: "secondary",
698
+ isLoading: mutation.isPending,
699
+ onClick: () => mutation.mutate(move, {
700
+ onSuccess: (o) => setResult(moveOutcomeCopy(o)),
701
+ onError: () => setResult({ variant: "error", text: "That couldn't be done. Nothing was changed — try again." })
702
+ }),
703
+ children: "Move to Held payments"
704
+ }
705
+ ),
706
+ result && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: result.variant === "error" ? "text-ui-fg-error" : "text-ui-fg-subtle", children: result.text })
707
+ ] });
708
+ };
637
709
  const SetupCheckPanel = () => {
638
710
  const check = useSetupCheck();
639
711
  const items = check.data?.items ?? [];
@@ -652,7 +724,8 @@ const SetupCheckPanel = () => {
652
724
  /* @__PURE__ */ jsxRuntime.jsx(ui.StatusBadge, { color: COLOR[i.status], className: "w-fit shrink-0", children: LABEL[i.status] }),
653
725
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col", children: [
654
726
  /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", children: i.detail }),
655
- i.fix && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: i.fix })
727
+ i.fix && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: i.fix }),
728
+ uniqueMoves(i.moves).length > 0 && /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "mt-2 flex flex-col gap-y-2", "aria-label": "Paid checkouts with no order", children: uniqueMoves(i.moves).map((m) => /* @__PURE__ */ jsxRuntime.jsx(MoveRow, { move: m }, m.vp_session_id)) })
656
729
  ] })
657
730
  ] }, `${i.id}:${i.provider ?? ""}:${n}`)) })
658
731
  ] })
@@ -825,6 +898,130 @@ const MarkResolvedAction = ({ row, onResult }) => {
825
898
  ] }) })
826
899
  ] });
827
900
  };
901
+ const localTime = (iso) => new Date(iso).toLocaleString();
902
+ const TIMER_MAX_MS = 2147483647;
903
+ const REPOLL_MS = 15e3;
904
+ const REPOLL_MAX = 8;
905
+ const ChargeActions = ({ row, listAt, onResult }) => {
906
+ const [answered, setAnswer] = react.useState(null);
907
+ const answer = answered && !(listAt > answered.receivedAt) ? answered : null;
908
+ const [asking, setAsking] = react.useState(null);
909
+ const [typed, setTyped] = react.useState("");
910
+ const [checked, setChecked] = react.useState(false);
911
+ const act = useChargeAction();
912
+ const qc = reactQuery.useQueryClient();
913
+ const rowSays = JSON.stringify([row.held_at, row.actions ?? null, row.hint ?? null]);
914
+ react.useEffect(() => setAnswer(null), [rowSays]);
915
+ const actions = answer?.actions ?? row.actions ?? [];
916
+ const hint = answer ? answer.hint : row.hint ?? null;
917
+ const closedUntil = actions.filter((a) => !a.available && a.available_at).map((a) => Date.parse(a.available_at)).filter((t) => Number.isFinite(t));
918
+ const opensAt = closedUntil.filter((t) => t > Date.now());
919
+ const nextOpen = opensAt.length > 0 ? Math.min(...opensAt) : null;
920
+ react.useEffect(() => {
921
+ if (nextOpen === null) return;
922
+ const timer = setTimeout(() => {
923
+ setAnswer(null);
924
+ void qc.invalidateQueries({ queryKey: [HELD_LIST_KEY] });
925
+ }, Math.min(TIMER_MAX_MS, Math.max(0, nextOpen - Date.now()) + 1e3));
926
+ return () => clearTimeout(timer);
927
+ }, [nextOpen, qc]);
928
+ const overdue = closedUntil.filter((t) => t <= Date.now());
929
+ const pastDue = overdue.length > 0 ? Math.min(...overdue) : null;
930
+ const repolls = react.useRef(0);
931
+ react.useEffect(() => {
932
+ if (pastDue === null || repolls.current >= REPOLL_MAX) return;
933
+ const timer = setTimeout(() => {
934
+ repolls.current += 1;
935
+ void qc.invalidateQueries({ queryKey: [HELD_LIST_KEY] });
936
+ }, REPOLL_MS);
937
+ return () => clearTimeout(timer);
938
+ }, [pastDue, listAt, qc]);
939
+ const run = (a) => act.mutate(
940
+ { row, action: a.id, ...a.confirm === "type_order_id" ? { typed_order_id: typed, checked_in_vonpay: checked } : {} },
941
+ {
942
+ onSuccess: (o) => {
943
+ if (o.recorded === false || o.outcome === "not_saved") {
944
+ setAnswer({ ...o, actions: [], hint: null, receivedAt: Date.now() });
945
+ onResult({ variant: "warning", text: NOT_SAVED_TEXT });
946
+ void qc.invalidateQueries({ queryKey: [HELD_LIST_KEY] });
947
+ return;
948
+ }
949
+ setAnswer({ ...o, receivedAt: Date.now() });
950
+ onResult({ variant: o.tone, text: fillTime(o.text, o.at, localTime) });
951
+ },
952
+ onError: (e) => {
953
+ const f = postFailure(e);
954
+ onResult(f.variant === "error" ? f : { variant: "warning", text: "Couldn't confirm the outcome. Reload the list." });
955
+ },
956
+ onSettled: () => setAsking(null)
957
+ }
958
+ );
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 ?? ""}`;
961
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
962
+ hint && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-subtle", children: fillTime(hint.text, hint.at, localTime) }),
963
+ 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: [
964
+ /* @__PURE__ */ jsxRuntime.jsx(
965
+ ui.Button,
966
+ {
967
+ size: "small",
968
+ variant: a.id === "check" ? "primary" : "secondary",
969
+ disabled: !a.available || act.isPending,
970
+ isLoading: act.isPending && (asking?.id === a.id || a.id === "check" && !asking),
971
+ onClick: () => {
972
+ if (!a.available) return;
973
+ if (!a.confirm) {
974
+ run(a);
975
+ return;
976
+ }
977
+ setTyped("");
978
+ setChecked(false);
979
+ setAsking(a);
980
+ },
981
+ children: a.label
982
+ }
983
+ ),
984
+ !a.available && a.available_at && /* @__PURE__ */ jsxRuntime.jsxs(ui.Text, { size: "xsmall", className: "text-ui-fg-muted", children: [
985
+ "Opens ",
986
+ localTime(a.available_at)
987
+ ] })
988
+ ] }, a.id)) }),
989
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt, { open: asking !== null, onOpenChange: (o) => !act.isPending && !o && setAsking(null), variant: "confirmation", children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Content, { children: [
990
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Header, { children: [
991
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Title, { children: [
992
+ asking?.label.replace(/…$/, ""),
993
+ "?"
994
+ ] }),
995
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Description, { children: asking?.confirm_text })
996
+ ] }),
997
+ asking?.confirm === "type_order_id" && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2 px-6 pb-4", children: [
998
+ /* @__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 })
1001
+ ] }),
1002
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Input, { id: inputId, size: "small", autoComplete: "off", value: typed, onChange: (e) => setTyped(e.target.value) }),
1003
+ /* @__PURE__ */ jsxRuntime.jsxs("label", { className: "flex items-center gap-x-2", children: [
1004
+ /* @__PURE__ */ jsxRuntime.jsx("input", { type: "checkbox", checked, onChange: (e) => setChecked(e.target.checked) }),
1005
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: "txt-compact-small", children: "I checked this order in the Vonpay dashboard" })
1006
+ ] })
1007
+ ] }),
1008
+ /* @__PURE__ */ jsxRuntime.jsxs(ui.Prompt.Footer, { children: [
1009
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Prompt.Cancel, { disabled: act.isPending, children: "Cancel" }),
1010
+ /* @__PURE__ */ jsxRuntime.jsx(
1011
+ ui.Prompt.Action,
1012
+ {
1013
+ disabled: act.isPending || !typedOk,
1014
+ onClick: (e) => {
1015
+ e.preventDefault();
1016
+ if (asking && typedOk) run(asking);
1017
+ },
1018
+ children: asking?.label.replace(/…$/, "")
1019
+ }
1020
+ )
1021
+ ] })
1022
+ ] }) })
1023
+ ] });
1024
+ };
828
1025
  const WalletHoldAction = ({ row, kind, onResult }) => {
829
1026
  const [open, setOpen] = react.useState(false);
830
1027
  const release = useReleaseHold();
@@ -860,7 +1057,7 @@ const WalletHoldAction = ({ row, kind, onResult }) => {
860
1057
  ] }) })
861
1058
  ] });
862
1059
  };
863
- const HoldDrawer = ({ row, onClose }) => {
1060
+ const HoldDrawer = ({ row, listAt, onClose }) => {
864
1061
  const copy = row ? reasonCopy(row.reason) : void 0;
865
1062
  const known = row ? isKnownStatus(row.status) && !isUnrecognisedRecord(row) : false;
866
1063
  const showRefund = !!row && known && canRefund(row);
@@ -877,7 +1074,7 @@ const HoldDrawer = ({ row, onClose }) => {
877
1074
  return /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer, { open: row !== null, onOpenChange: (open) => !open && close(), children: /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Content, { children: [
878
1075
  /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Header, { children: [
879
1076
  /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Title, { children: copy?.label ?? "Held payment" }),
880
- /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Description, { children: copy?.what })
1077
+ /* @__PURE__ */ jsxRuntime.jsx(ui.Drawer.Description, { children: row && isChargeRow(row) ? void 0 : copy?.what })
881
1078
  ] }),
882
1079
  row && /* @__PURE__ */ jsxRuntime.jsxs(ui.Drawer.Body, { className: "flex flex-col gap-y-6 overflow-y-auto", children: [
883
1080
  /* @__PURE__ */ jsxRuntime.jsxs("dl", { className: "grid grid-cols-[auto_1fr] gap-x-4 gap-y-2", children: [
@@ -902,6 +1099,10 @@ const HoldDrawer = ({ row, onClose }) => {
902
1099
  /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Order" }),
903
1100
  /* @__PURE__ */ jsxRuntime.jsx("dd", { children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.order_id, label: "order id" }) })
904
1101
  ] }),
1102
+ row.vp_payment_intent_id && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
1103
+ /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Vonpay payment" }),
1104
+ /* @__PURE__ */ jsxRuntime.jsx("dd", { children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.vp_payment_intent_id, label: "Vonpay payment id" }) })
1105
+ ] }),
905
1106
  /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Vonpay session" }),
906
1107
  /* @__PURE__ */ jsxRuntime.jsx("dd", { children: /* @__PURE__ */ jsxRuntime.jsx(Mono, { value: row.vp_session_id, label: "Vonpay session id" }) }),
907
1108
  /* @__PURE__ */ jsxRuntime.jsx("dt", { className: "txt-compact-small text-ui-fg-subtle", children: "Medusa payment session" }),
@@ -930,6 +1131,7 @@ const HoldDrawer = ({ row, onClose }) => {
930
1131
  ] })
931
1132
  ] }),
932
1133
  result && /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: result.variant, children: result.text }),
1134
+ closedMoneyNote(row) && /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: closedMoneyNote(row) }),
933
1135
  (showRefund || showMark || showRelease || showComplete || showOpenOrder) && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap gap-2", children: [
934
1136
  showOpenOrder && /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "primary", asChild: true, children: /* @__PURE__ */ jsxRuntime.jsx(reactRouterDom.Link, { to: orderPath(row.order_id), children: "Open order" }) }),
935
1137
  showComplete && /* @__PURE__ */ jsxRuntime.jsx(WalletHoldAction, { row, kind: "complete", onResult: setResult }),
@@ -938,6 +1140,7 @@ const HoldDrawer = ({ row, onClose }) => {
938
1140
  showRefund && /* @__PURE__ */ jsxRuntime.jsx(CheckWithVonpayAction, { row, onResult: setResult }),
939
1141
  showMark && /* @__PURE__ */ jsxRuntime.jsx(MarkResolvedAction, { row, onResult: setResult })
940
1142
  ] }),
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 ?? ""}`),
941
1144
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-4", children: [
942
1145
  /* @__PURE__ */ jsxRuntime.jsx(ui.Heading, { level: "h3", children: "Or do it by hand" }),
943
1146
  steps.map((step) => /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col gap-y-2", children: [
@@ -948,7 +1151,7 @@ const HoldDrawer = ({ row, onClose }) => {
948
1151
  /* @__PURE__ */ jsxRuntime.jsx(ui.CodeBlock.Body, {})
949
1152
  ] })
950
1153
  ] }, step.title)),
951
- /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-muted", children: STILL_LISTED_NOTE })
1154
+ !isChargeRow(row) && /* @__PURE__ */ jsxRuntime.jsx(ui.Text, { size: "small", className: "text-ui-fg-muted", children: STILL_LISTED_NOTE })
952
1155
  ] })
953
1156
  ] })
954
1157
  ] }) });
@@ -958,11 +1161,11 @@ const HeldPaymentsPage = () => {
958
1161
  const [pageIndex, setPageIndex] = react.useState(0);
959
1162
  const [filter, setFilter] = react.useState("open");
960
1163
  const [opened, setOpened] = react.useState(null);
961
- const { data, isLoading, isError, refetch } = useHeldPayments({ limit: HELD_PAGE_SIZE, offset: pageIndex * HELD_PAGE_SIZE, status: filter });
1164
+ const { data, dataUpdatedAt, isLoading, isError, refetch } = useHeldPayments({ limit: HELD_PAGE_SIZE, offset: pageIndex * HELD_PAGE_SIZE, status: filter });
962
1165
  const count = data?.count ?? 0;
963
1166
  const remainder = count % HELD_PAGE_SIZE;
964
1167
  const pageCount = Math.max(1, (count - remainder) / HELD_PAGE_SIZE + (remainder > 0 ? 1 : 0));
965
- const keyOf = (row) => `${row.payment_collection_id}:${row.vp_session_id ?? "unreadable"}:${row.source}:${row.payment_id ?? ""}:${row.medusa_payment_session_id ?? ""}`;
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 ?? ""}`;
966
1169
  const open = opened ? data?.holds.find((r) => keyOf(r) === keyOf(opened)) ?? opened : null;
967
1170
  const onRowKey = (row) => (e) => {
968
1171
  if (e.key === "Enter" || e.key === " ") {
@@ -994,6 +1197,8 @@ const HeldPaymentsPage = () => {
994
1197
  ) })
995
1198
  ] }),
996
1199
  data && !data.scan_complete && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: "This list may be incomplete: the store has more records than one page load can check." }) }),
1200
+ data?.job?.filter((j) => j.banner).map((j) => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: j.banner }) }, j.mode)),
1201
+ data && chargedUncheckedNote(data.charged_unchecked) && /* @__PURE__ */ jsxRuntime.jsx("div", { className: "px-6 py-4", children: /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "warning", children: chargedUncheckedNote(data.charged_unchecked) }) }),
997
1202
  isError ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-col items-start gap-y-3 px-6 py-6", children: [
998
1203
  /* @__PURE__ */ jsxRuntime.jsx(ui.Alert, { variant: "error", children: "Couldn't load held payments." }),
999
1204
  /* @__PURE__ */ jsxRuntime.jsx(ui.Button, { size: "small", variant: "secondary", onClick: () => refetch(), children: "Try again" })
@@ -1045,7 +1250,7 @@ const HeldPaymentsPage = () => {
1045
1250
  }
1046
1251
  )
1047
1252
  ] }),
1048
- /* @__PURE__ */ jsxRuntime.jsx(HoldDrawer, { row: open, onClose: () => setOpened(null) })
1253
+ /* @__PURE__ */ jsxRuntime.jsx(HoldDrawer, { row: open, listAt: dataUpdatedAt, onClose: () => setOpened(null) })
1049
1254
  ] }),
1050
1255
  /* @__PURE__ */ jsxRuntime.jsx(SetupCheckPanel, {})
1051
1256
  ] });