medusa-payment-doit 0.1.0 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.1] - 2026-10-25
10
+
11
+ ### Changed
12
+
13
+ - Peer dependencies widened to `^2.19.0` for the `@medusajs/*` packages (and `^4.2.1` for `@medusajs/ui`, which has
14
+ its own versioning), so the plugin installs on newer Medusa 2.x releases without overrides. Development still uses
15
+ the 2.19.0 lock.
16
+ - The npm package now includes `examples/nextjs-starter` (components, `starter.patch` and its README), so the
17
+ storefront example is available in `node_modules/medusa-payment-doit/examples/nextjs-starter` without cloning
18
+ the repository.
19
+ - npm keywords for the Medusa integrations directory: `medusa-plugin-integration`, `medusa-plugin-payment`.
20
+
21
+ ### Added
22
+
23
+ - CI matrix running the unit and E2E suites on Medusa 2.19.0 and 2.22.0, with the package built on the locked
24
+ 2.19.0 toolchain. `scripts/use-medusa-version.mjs` switches a checkout to another Medusa release for this.
25
+
9
26
  ## [0.1.0] - 2026-10-25
10
27
 
11
28
  First public beta (`npm install medusa-payment-doit@beta`). Requires Medusa 2.19.x.
@@ -37,5 +54,6 @@ First public beta (`npm install medusa-payment-doit@beta`). Requires Medusa 2.19
37
54
  - Example components for the Medusa Next.js Starter (`examples/nextjs-starter`).
38
55
  - E2E suites against a mock doit.id and an opt-in sandbox suite; CI on every push and pull request.
39
56
 
40
- [Unreleased]: https://github.com/haustex/pg-medusa-doit/compare/v0.1.0...HEAD
57
+ [Unreleased]: https://github.com/haustex/pg-medusa-doit/compare/v0.1.1...HEAD
58
+ [0.1.1]: https://github.com/haustex/pg-medusa-doit/compare/v0.1.0...v0.1.1
41
59
  [0.1.0]: https://github.com/haustex/pg-medusa-doit/releases/tag/v0.1.0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  [![npm](https://img.shields.io/npm/v/medusa-payment-doit/beta?label=npm%40beta)](https://www.npmjs.com/package/medusa-payment-doit)
7
7
  [![CI](https://github.com/haustex/pg-medusa-doit/actions/workflows/ci.yml/badge.svg)](https://github.com/haustex/pg-medusa-doit/actions/workflows/ci.yml)
8
- [![Medusa](https://img.shields.io/badge/Medusa-2.19-7c3aed)](https://medusajs.com)
8
+ [![Medusa](https://img.shields.io/badge/Medusa-2.19%20%E2%80%93%202.22-7c3aed)](https://medusajs.com)
9
9
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
10
10
 
11
11
  ```bash
@@ -32,7 +32,7 @@ More: [settings](https://raw.githubusercontent.com/haustex/pg-medusa-doit/main/d
32
32
 
33
33
  ### Features
34
34
 
35
- - **Payment provider** for Medusa 2.19: QRIS, Virtual Account (8 banks), the doit.id hosted page (`any`), and bank
35
+ - **Payment provider** for Medusa 2.19+: QRIS, Virtual Account (8 banks), the doit.id hosted page (`any`), and bank
36
36
  transfer (`transfer`, see [Limitations](#limitations)). Amounts are whole rupiah, exactly as Medusa stores them.
37
37
  - **Credentials in the Admin**, encrypted with AES-256-GCM. Falls back to provider options or env vars.
38
38
  - **Webhooks**: signature checked on the raw body (HMAC-SHA256, ±5 min), deduplicated by event id in the database,
@@ -50,7 +50,7 @@ More: [settings](https://raw.githubusercontent.com/haustex/pg-medusa-doit/main/d
50
50
 
51
51
  ### Requirements
52
52
 
53
- - Medusa **2.19.x**, Node 20.19+ or 22.12+
53
+ - Medusa **2.19 or newer 2.x** (tested in CI on 2.19.0 and 2.22.0), Node 20.19+ or 22.12+
54
54
  - Redis for the event bus, workflow engine and locking (strongly recommended; webhooks are processed in the worker)
55
55
  - A doit.id account: `pb_test_…` key for the sandbox, `pb_live_…` for production
56
56
 
@@ -162,7 +162,8 @@ for the status, 20/min per cart and 30/min per IP for methods (in memory, per se
162
162
  once per 10 seconds per payment.
163
163
 
164
164
  **Medusa Next.js Starter:** copy-in components and a patch in
165
- [`examples/nextjs-starter`](examples/nextjs-starter/README.md).
165
+ [`examples/nextjs-starter`](examples/nextjs-starter/README.md), also shipped in the npm package
166
+ (`node_modules/medusa-payment-doit/examples/nextjs-starter`).
166
167
 
167
168
  Switching method creates a new invoice. Switching back to a method whose invoice is still open (same cart, same amount,
168
169
  VA with ≥5 minutes left, QRIS whose QR is still issued) reuses that invoice.
@@ -211,7 +212,7 @@ Report vulnerabilities privately: see [SECURITY.md](SECURITY.md).
211
212
 
212
213
  ### Fitur
213
214
 
214
- - **Payment provider** untuk Medusa 2.19: QRIS, Virtual Account (8 bank), halaman bayar hosted doit.id (`any`), dan
215
+ - **Payment provider** untuk Medusa 2.19+: QRIS, Virtual Account (8 bank), halaman bayar hosted doit.id (`any`), dan
215
216
  transfer bank (`transfer`, lihat [Batasan](#batasan)). Nominal rupiah utuh, persis seperti disimpan Medusa.
216
217
  - **Kredensial di Admin**, terenkripsi AES-256-GCM, dengan cadangan dari options provider atau env.
217
218
  - **Webhook**: signature dicek pada raw body (HMAC-SHA256, ±5 menit), dedupe per event id di database, diproses worker
@@ -228,7 +229,7 @@ Report vulnerabilities privately: see [SECURITY.md](SECURITY.md).
228
229
 
229
230
  ### Kebutuhan
230
231
 
231
- - Medusa **2.19.x**, Node 20.19+ atau 22.12+
232
+ - Medusa **2.19 atau 2.x yang lebih baru** (dites di CI pada 2.19.0 dan 2.22.0), Node 20.19+ atau 22.12+
232
233
  - Redis untuk event bus, workflow engine, dan locking (sangat disarankan; webhook diproses di worker)
233
234
  - Akun doit.id: key `pb_test_…` untuk sandbox, `pb_live_…` untuk produksi
234
235
 
@@ -314,7 +315,8 @@ Aturan akses: cart harus milik sales channel publishable key, dan cart customer
314
315
  methods 20/menit per cart dan 30/menit per IP (di memori, per proses server). doit.id dibaca paling sering sekali per
315
316
  10 detik per pembayaran.
316
317
 
317
- **Medusa Next.js Starter:** komponen dan patch di [`examples/nextjs-starter`](examples/nextjs-starter/README.md).
318
+ **Medusa Next.js Starter:** komponen dan patch di [`examples/nextjs-starter`](examples/nextjs-starter/README.md),
319
+ ikut di paket npm (`node_modules/medusa-payment-doit/examples/nextjs-starter`).
318
320
 
319
321
  Ganti metode membuat invoice baru. Kembali ke metode yang invoice-nya masih aktif (cart dan nominal sama, VA sisa ≥5
320
322
  menit, QRIS yang QR-nya masih terbit) memakai ulang invoice itu.
@@ -0,0 +1,109 @@
1
+ # doit.id checkout for the Medusa Next.js Starter
2
+
3
+ Drop-in files for [`nextjs-starter-medusa`](https://github.com/medusajs/nextjs-starter-medusa)
4
+ (tested against commit `9818886`, Next.js 15, Medusa 2.19; the store endpoints it uses run in CI on Medusa 2.19 and 2.22) that add a doit.id payment step:
5
+
6
+ - **Pick a method**: QRIS or a Virtual Account bank. Only methods doit.id has active for your key are
7
+ listed, each with the fee and the total to pay for this cart (`GET /store/doit/methods`). Methods outside their
8
+ amount limits are shown disabled. Who pays the fee is set per method in the doit.id portal, not by the plugin.
9
+ - **QRIS**: renders `qr_content` as an SVG QR code ([`qrcode.react`](https://www.npmjs.com/package/qrcode.react), no canvas, no network).
10
+ - **Virtual Account**: shows the number with a copy button.
11
+ - **Countdown** to `expires_at`.
12
+ - **Polling** of `GET /store/doit/carts/:id/payment`. When the payment is paid, the order is placed and the
13
+ shopper lands on the starter's confirmation page. This works even when webhooks can't reach you
14
+ (e.g. on localhost).
15
+ - **Hosted page**: a link to doit.id's `hosted_url`. Set `NEXT_PUBLIC_DOIT_REDIRECT_TO_HOSTED=true` to send
16
+ shoppers there straight away. They come back to the review step through `return_url`.
17
+
18
+ The styling is plain: the starter's Tailwind tokens and `@medusajs/ui`, with no logos or brand colours. All
19
+ text is in [`labels.ts`](src/modules/checkout/components/doit-payment/labels.ts) (Indonesian), so you can
20
+ restyle or translate it in one place.
21
+
22
+ | QRIS | Virtual Account |
23
+ | --- | --- |
24
+ | ![QRIS](https://raw.githubusercontent.com/haustex/pg-medusa-doit/main/docs/screenshots/storefront-qris.jpg) | ![VA](https://raw.githubusercontent.com/haustex/pg-medusa-doit/main/docs/screenshots/storefront-va.jpg) |
25
+
26
+ ## Install
27
+
28
+ 1. Install and configure the plugin in your Medusa backend (see the main README). Enable the provider
29
+ `pp_doit_doit` in your IDR region.
30
+ 2. In the storefront:
31
+
32
+ ```bash
33
+ npm install qrcode.react@^4.2.0
34
+ ```
35
+
36
+ 3. Copy `src/` from this folder over your storefront's `src/` (it is also in the npm package:
37
+ `node_modules/medusa-payment-doit/examples/nextjs-starter/src`). It only adds new files:
38
+
39
+ ```
40
+ src/lib/data/doit.ts server actions (SDK calls)
41
+ src/modules/checkout/components/doit-payment/index.tsx isDoit, DoitPaymentButton
42
+ src/modules/checkout/components/doit-payment/rail-picker.tsx
43
+ src/modules/checkout/components/doit-payment/payment-panel.tsx
44
+ src/modules/checkout/components/doit-payment/labels.ts all shopper-facing text
45
+ src/modules/checkout/components/doit-payment/format.ts rupiah + countdown helpers
46
+ ```
47
+
48
+ 4. Wire it into three starter files. Either apply the patch:
49
+
50
+ ```bash
51
+ git apply path/to/examples/nextjs-starter/starter.patch
52
+ ```
53
+
54
+ or make the same changes by hand:
55
+
56
+ **`src/lib/constants.tsx`**: add the provider to `paymentInfoMap`:
57
+
58
+ ```tsx
59
+ pp_doit_doit: { title: "QRIS / Virtual Account", icon: <CreditCard /> },
60
+ ```
61
+
62
+ **`src/modules/checkout/components/payment/index.tsx`**: show the method picker and pass the choice
63
+ when the session is created:
64
+
65
+ ```tsx
66
+ import { DoitChoice, selectDoitMethod } from "@lib/data/doit"
67
+ import { DoitRailPicker, isDoit } from "@modules/checkout/components/doit-payment"
68
+
69
+ const [doitChoice, setDoitChoice] = useState<DoitChoice | null>(/* from activeSession.data */)
70
+
71
+ // in handleSubmit, before the generic initiatePaymentSession call:
72
+ if (isDoit(selectedPaymentMethod)) {
73
+ const res = await selectDoitMethod(cart, selectedPaymentMethod, doitChoice!,
74
+ `${window.location.origin}${pathname}?step=review`)
75
+ if (!res.ok) throw new Error(res.message)
76
+ } else if (!checkActiveSession) { /* starter code */ }
77
+
78
+ // under the <RadioGroup>:
79
+ {isDoit(selectedPaymentMethod) && (
80
+ <DoitRailPicker cartId={cart.id} value={doitChoice} onChange={setDoitChoice} />
81
+ )}
82
+ ```
83
+
84
+ **`src/modules/checkout/components/payment-button/index.tsx`**: add a case to the switch:
85
+
86
+ ```tsx
87
+ case isDoit(paymentSession?.provider_id):
88
+ return <DoitPaymentButton cart={cart} notReady={notReady} />
89
+ ```
90
+
91
+ ## How it behaves
92
+
93
+ | Backend `next_action` | Panel |
94
+ | --- | --- |
95
+ | `wait` | Shows QR / VA, countdown, hosted link; polls again after `poll_after_ms` (5 s). Pauses while the tab is hidden. |
96
+ | `complete_cart` | Payment is paid: calls the starter's `placeOrder()`, which completes the cart and redirects. |
97
+ | `view_order` | The webhook already completed the cart: `placeOrder()` returns that same order and redirects. |
98
+ | `retry` | Expired or failed: offers "Buat tagihan baru" (new invoice with the same method). |
99
+
100
+ When `qr_expired` is true (the QR image lives about 10 minutes, shorter than the invoice), the panel replaces the QR with
101
+ a button to the hosted page, where doit.id issues a new QR.
102
+
103
+ The status endpoint only answers for a cart that belongs to the publishable key's sales channel. If the
104
+ cart belongs to a registered customer, that customer has to be logged in. Requests are rate limited
105
+ (40/min per cart, 120/min per IP), and the backend reads doit.id at most once per 10 s per payment. A
106
+ shopper who changes method gets a new invoice; switching back to a method whose invoice is still open reuses it. An
107
+ abandoned invoice is not cancelled at doit.id (there is no cancel endpoint) and simply expires.
108
+
109
+ Community plugin, not an official doit.id product.
@@ -0,0 +1,140 @@
1
+ "use server"
2
+
3
+ import { sdk } from "@lib/config"
4
+ import { HttpTypes } from "@medusajs/types"
5
+ import { revalidateTag } from "next/cache"
6
+ import { getAuthHeaders, getCacheTag } from "./cookies"
7
+
8
+ /** What the shopper picked: QRIS, or a VA / transfer bank. */
9
+ export type DoitChoice = {
10
+ rail: "qris" | "va" | "transfer" | "any"
11
+ va_bank?: string | null
12
+ }
13
+
14
+ export type DoitMethod = {
15
+ code: string
16
+ label: string
17
+ rail: "qris" | "va" | "transfer"
18
+ bank: string | null
19
+ fee_payer: string | null
20
+ fee_amount: number | null
21
+ total_amount: number | null
22
+ /** false: the cart total is outside this method's limits; show it disabled with `limit` */
23
+ within_limits: boolean | null
24
+ limit: string | null
25
+ }
26
+
27
+ export type DoitMethods = {
28
+ provider_id: string
29
+ amount: number | null
30
+ default: { rail: DoitChoice["rail"]; va_bank: string | null }
31
+ methods: DoitMethod[]
32
+ source: "doit" | "unavailable"
33
+ }
34
+
35
+ export type DoitPaymentStatus = {
36
+ cart_id: string
37
+ payment_session_id: string
38
+ order_id: string | null
39
+ /** wait: keep polling · complete_cart: paid, place the order · view_order: done · retry: expired / failed */
40
+ next_action: "wait" | "complete_cart" | "view_order" | "retry"
41
+ poll_after_ms: number | null
42
+ /** the QRIS image (≈10 min) expired before the invoice: send the shopper to hosted_url */
43
+ qr_expired: boolean
44
+ payment: {
45
+ id: string | null
46
+ status: "pending" | "paid" | "expired" | "failed" | "refunded" | null
47
+ rail: string | null
48
+ va_bank: string | null
49
+ va_number: string | null
50
+ qr_content: string | null
51
+ hosted_url: string | null
52
+ expires_at: string | null
53
+ paid_at: string | null
54
+ amount: number | null
55
+ fee_amount: number | null
56
+ fee_payer: "merchant" | "customer" | null
57
+ total_amount: number | null
58
+ environment: string | null
59
+ }
60
+ }
61
+
62
+ export type DoitResult<T> = { ok: true; data: T } | { ok: false; status: number; message: string }
63
+
64
+ function toError(e: any): { ok: false; status: number; message: string } {
65
+ return { ok: false, status: e?.status ?? 0, message: e?.message ?? "Gagal menghubungi server." }
66
+ }
67
+
68
+ /** Methods doit.id has active for this store, with the fee for the cart total. Cached by the backend for 5 minutes. */
69
+ export async function listDoitMethods(cartId: string): Promise<DoitResult<DoitMethods>> {
70
+ try {
71
+ const data = await sdk.client.fetch<DoitMethods>("/store/doit/methods", {
72
+ method: "GET",
73
+ query: { cart_id: cartId },
74
+ headers: { ...(await getAuthHeaders()) },
75
+ cache: "no-store",
76
+ })
77
+ return { ok: true, data }
78
+ } catch (e) {
79
+ return toError(e)
80
+ }
81
+ }
82
+
83
+ /** Status for the "waiting for payment" panel. Safe to poll: the backend throttles reads of doit.id. */
84
+ export async function getDoitPaymentStatus(cartId: string): Promise<DoitResult<DoitPaymentStatus>> {
85
+ try {
86
+ const data = await sdk.client.fetch<DoitPaymentStatus>(`/store/doit/carts/${cartId}/payment`, {
87
+ method: "GET",
88
+ headers: { ...(await getAuthHeaders()) },
89
+ cache: "no-store",
90
+ })
91
+ return { ok: true, data }
92
+ } catch (e) {
93
+ return toError(e)
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Creates (or replaces) the cart's doit payment session for the chosen
99
+ * method. A session with the same rail / bank that is still pending is kept,
100
+ * so going back and forth in the checkout does not create new invoices.
101
+ */
102
+ export async function selectDoitMethod(
103
+ cart: HttpTypes.StoreCart,
104
+ providerId: string,
105
+ choice: DoitChoice,
106
+ returnUrl?: string,
107
+ { force = false }: { force?: boolean } = {}
108
+ ): Promise<DoitResult<null>> {
109
+ const current = cart.payment_collection?.payment_sessions?.find(
110
+ (s) => s.provider_id === providerId && s.status === "pending"
111
+ )
112
+ const same =
113
+ current &&
114
+ current.data?.status === "pending" &&
115
+ (current.data?.requested_rail ?? current.data?.rail) === choice.rail &&
116
+ (choice.rail === "qris" || choice.rail === "any" || current.data?.va_bank === choice.va_bank)
117
+ if (same && !force) {
118
+ return { ok: true, data: null }
119
+ }
120
+
121
+ try {
122
+ await sdk.store.payment.initiatePaymentSession(
123
+ cart,
124
+ {
125
+ provider_id: providerId,
126
+ data: {
127
+ rail: choice.rail,
128
+ ...(choice.va_bank ? { va_bank: choice.va_bank } : {}),
129
+ ...(returnUrl ? { return_url: returnUrl } : {}),
130
+ },
131
+ },
132
+ {},
133
+ { ...(await getAuthHeaders()) }
134
+ )
135
+ revalidateTag(await getCacheTag("carts"))
136
+ return { ok: true, data: null }
137
+ } catch (e) {
138
+ return toError(e)
139
+ }
140
+ }
@@ -0,0 +1,22 @@
1
+ import { convertToLocale } from "@lib/util/money"
2
+
3
+ /** Rupiah as stored by Medusa and doit.id (Rp150.000 = 150000): no scaling. */
4
+ export function rupiah(amount: number | null | undefined): string {
5
+ return convertToLocale({
6
+ amount: amount ?? 0,
7
+ currency_code: "idr",
8
+ locale: "id-ID",
9
+ minimumFractionDigits: 0,
10
+ maximumFractionDigits: 0,
11
+ })
12
+ }
13
+
14
+ /** "02:14:05" / "14:05" until `until`; "00:00" when past. */
15
+ export function remaining(until: number, now: number): string {
16
+ const total = Math.max(0, Math.floor((until - now) / 1000))
17
+ const h = Math.floor(total / 3600)
18
+ const m = Math.floor((total % 3600) / 60)
19
+ const s = total % 60
20
+ const pad = (n: number) => String(n).padStart(2, "0")
21
+ return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
22
+ }
@@ -0,0 +1,40 @@
1
+ "use client"
2
+
3
+ import { HttpTypes } from "@medusajs/types"
4
+ import { Button } from "@medusajs/ui"
5
+ import { doitLabels } from "./labels"
6
+ import DoitPaymentPanel from "./payment-panel"
7
+
8
+ export { default as DoitRailPicker } from "./rail-picker"
9
+ export { default as DoitPaymentPanel } from "./payment-panel"
10
+ export { doitLabels }
11
+
12
+ /** True for any doit.id provider registered in Medusa (pp_doit_<id>). */
13
+ export const isDoit = (providerId?: string) => !!providerId?.startsWith("pp_doit_")
14
+
15
+ /**
16
+ * Drop-in for the review step's PaymentButton: once address, email and
17
+ * shipping are set, shows the payment panel instead of a "Place order" button.
18
+ * The order is placed automatically when doit.id reports the payment as paid.
19
+ */
20
+ export function DoitPaymentButton({
21
+ cart,
22
+ notReady,
23
+ redirectToHosted = process.env.NEXT_PUBLIC_DOIT_REDIRECT_TO_HOSTED === "true",
24
+ }: {
25
+ cart: HttpTypes.StoreCart
26
+ notReady: boolean
27
+ redirectToHosted?: boolean
28
+ }) {
29
+ const session = cart.payment_collection?.payment_sessions?.find(
30
+ (s) => isDoit(s.provider_id) && s.status === "pending"
31
+ )
32
+ if (notReady || !session) {
33
+ return (
34
+ <Button disabled size="large">
35
+ {doitLabels.payButton}
36
+ </Button>
37
+ )
38
+ }
39
+ return <DoitPaymentPanel cart={cart} providerId={session.provider_id} redirectToHosted={redirectToHosted} />
40
+ }
@@ -0,0 +1,55 @@
1
+ // All shopper-facing text of the doit.id checkout in one place. Edit or
2
+ // translate freely; nothing else in these components hard-codes copy.
3
+ export const doitLabels = {
4
+ methodTitle: "QRIS / Virtual Account",
5
+ chooseMethod: "Pilih cara bayar",
6
+ qris: "QRIS",
7
+ qrisHint: "Semua e-wallet dan m-banking",
8
+ va: "Virtual Account",
9
+ transfer: "Transfer bank",
10
+ anyMethod: "Pilih di halaman pembayaran",
11
+ feeByCustomer: (fee: string) => `+ biaya ${fee}`,
12
+ noFee: "Tanpa biaya tambahan",
13
+ totalWithFee: (total: string) => `Total ${total}`,
14
+ outsideLimit: (limit: string | null) => (limit ? `Tidak tersedia: ${limit}` : "Tidak tersedia untuk nominal ini"),
15
+ feeNote: "Biaya layanan ditetapkan doit.id per metode. Total yang dibayar tampil sebelum kamu memilih.",
16
+ methodsUnavailable: "Daftar metode tidak bisa dimuat. Metode bawaan toko akan dipakai.",
17
+ loading: "Memuat…",
18
+ payButton: "Bayar",
19
+
20
+ payTitle: "Selesaikan pembayaran",
21
+ totalToPay: "Total dibayar",
22
+ includesFee: (fee: string) => `Termasuk biaya ${fee}`,
23
+ scanQris: "Pindai kode QR ini dengan aplikasi e-wallet atau m-banking.",
24
+ vaNumber: (bank: string) => `Nomor Virtual Account ${bank}`,
25
+ vaHint: "Transfer tepat sesuai total ke nomor ini dari m-banking, internet banking, atau ATM.",
26
+ copy: "Salin",
27
+ copied: "Tersalin",
28
+ expiresIn: "Sisa waktu",
29
+ expired: "Waktu pembayaran habis",
30
+ openHosted: "Buka halaman pembayaran",
31
+ qrExpired: "Kode QR sudah tidak berlaku. Lanjutkan pembayaran di halaman doit.id untuk membuat kode baru.",
32
+ continueHosted: "Lanjutkan di halaman pembayaran",
33
+
34
+ waiting: "Menunggu pembayaran… halaman ini diperbarui otomatis.",
35
+ paid: "Pembayaran diterima. Membuat pesanan…",
36
+ failed: "Pembayaran gagal atau kedaluwarsa.",
37
+ newInvoice: "Buat tagihan baru",
38
+ testMode: "Mode uji (sandbox): tidak ada uang sungguhan.",
39
+ errorGeneric: "Status pembayaran belum bisa diperiksa. Mencoba lagi…",
40
+ }
41
+
42
+ export const bankNames: Record<string, string> = {
43
+ bmri: "Mandiri",
44
+ bnia: "BNI",
45
+ brin: "BRI",
46
+ cimb: "CIMB Niaga",
47
+ permata: "Permata",
48
+ maybank: "Maybank",
49
+ danamon: "Danamon",
50
+ bsyi: "BSI",
51
+ bca: "BCA",
52
+ mandiri: "Mandiri",
53
+ bni: "BNI",
54
+ bsi: "BSI",
55
+ }
@@ -0,0 +1,227 @@
1
+ "use client"
2
+
3
+ import { placeOrder } from "@lib/data/cart"
4
+ import { DoitPaymentStatus, getDoitPaymentStatus, selectDoitMethod } from "@lib/data/doit"
5
+ import { HttpTypes } from "@medusajs/types"
6
+ import { Button, Heading, Text, clx } from "@medusajs/ui"
7
+ import { useRouter } from "next/navigation"
8
+ import { QRCodeSVG } from "qrcode.react"
9
+ import { useCallback, useEffect, useRef, useState } from "react"
10
+ import { remaining, rupiah } from "./format"
11
+ import { bankNames, doitLabels as t } from "./labels"
12
+
13
+ type Props = {
14
+ cart: HttpTypes.StoreCart
15
+ providerId: string
16
+ /** Send the shopper straight to doit.id's hosted payment page instead of showing QR / VA here. */
17
+ redirectToHosted?: boolean
18
+ }
19
+
20
+ const ERROR_RETRY_MS = 10_000
21
+
22
+ /**
23
+ * "Waiting for payment" panel: QRIS code or VA number, countdown to
24
+ * expires_at, a link to the hosted page, and polling of
25
+ * GET /store/doit/carts/:id/payment. Once doit.id reports the payment as paid
26
+ * it places the order (the starter's placeOrder redirects to the confirmation
27
+ * page; if the webhook completed the cart first, Medusa returns that order).
28
+ */
29
+ export default function DoitPaymentPanel({ cart, providerId, redirectToHosted = false }: Props) {
30
+ const router = useRouter()
31
+ const [state, setState] = useState<DoitPaymentStatus | null>(null)
32
+ const [error, setError] = useState<string | null>(null)
33
+ const [busy, setBusy] = useState(false)
34
+ const [now, setNow] = useState(() => Date.now())
35
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
36
+ const placing = useRef(false)
37
+ const redirected = useRef(false)
38
+
39
+ const finish = useCallback(async () => {
40
+ if (placing.current) return
41
+ placing.current = true
42
+ try {
43
+ await placeOrder(cart.id) // redirects to /[countryCode]/order/[id]/confirmed
44
+ } catch (e: any) {
45
+ placing.current = false
46
+ setError(e?.message ?? t.errorGeneric)
47
+ }
48
+ }, [cart.id])
49
+
50
+ const poll = useCallback(async () => {
51
+ if (timer.current) clearTimeout(timer.current)
52
+ const res = await getDoitPaymentStatus(cart.id)
53
+ let next: number | null = ERROR_RETRY_MS
54
+ if (res.ok) {
55
+ setState(res.data)
56
+ setError(null)
57
+ next = res.data.poll_after_ms
58
+ if (res.data.next_action === "complete_cart" || res.data.next_action === "view_order") {
59
+ next = null
60
+ void finish()
61
+ }
62
+ } else {
63
+ setError(t.errorGeneric)
64
+ // 429: the backend asks to slow down
65
+ next = res.status === 429 ? ERROR_RETRY_MS * 3 : ERROR_RETRY_MS
66
+ }
67
+ if (next !== null && document.visibilityState === "visible") {
68
+ timer.current = setTimeout(poll, next)
69
+ }
70
+ }, [cart.id, finish])
71
+
72
+ useEffect(() => {
73
+ void poll()
74
+ const onVisible = () => {
75
+ if (document.visibilityState === "visible") void poll()
76
+ }
77
+ document.addEventListener("visibilitychange", onVisible)
78
+ return () => {
79
+ document.removeEventListener("visibilitychange", onVisible)
80
+ if (timer.current) clearTimeout(timer.current)
81
+ }
82
+ }, [poll])
83
+
84
+ useEffect(() => {
85
+ const id = setInterval(() => setNow(Date.now()), 1000)
86
+ return () => clearInterval(id)
87
+ }, [])
88
+
89
+ const p = state?.payment
90
+ useEffect(() => {
91
+ if (redirectToHosted && p?.hosted_url && state?.next_action === "wait" && !redirected.current) {
92
+ redirected.current = true
93
+ window.location.assign(p.hosted_url)
94
+ }
95
+ }, [redirectToHosted, p?.hosted_url, state?.next_action])
96
+
97
+ const newInvoice = async () => {
98
+ if (!p?.rail) return
99
+ setBusy(true)
100
+ const res = await selectDoitMethod(
101
+ cart,
102
+ providerId,
103
+ { rail: p.rail as "qris" | "va" | "transfer", va_bank: p.va_bank },
104
+ window.location.href,
105
+ { force: true }
106
+ )
107
+ setBusy(false)
108
+ if (!res.ok) {
109
+ setError(res.message)
110
+ return
111
+ }
112
+ router.refresh()
113
+ void poll()
114
+ }
115
+
116
+ if (!state) {
117
+ return (
118
+ <Text className="txt-medium text-ui-fg-subtle" data-testid="doit-panel-loading">
119
+ {error ?? t.loading}
120
+ </Text>
121
+ )
122
+ }
123
+
124
+ const expiresAt = p?.expires_at ? new Date(p.expires_at).getTime() : null
125
+ const isExpired = expiresAt !== null && expiresAt <= now
126
+ const total = p?.total_amount ?? p?.amount
127
+ const customerFee = p?.fee_payer === "customer" && p.fee_amount ? p.fee_amount : 0
128
+ const done = state.next_action === "complete_cart" || state.next_action === "view_order"
129
+ const retry = state.next_action === "retry"
130
+
131
+ return (
132
+ <div className="flex flex-col gap-y-6 rounded-rounded border border-ui-border-base p-6" data-testid="doit-payment-panel">
133
+ <div className="flex flex-col gap-y-1">
134
+ <Heading level="h3" className="txt-xlarge">{t.payTitle}</Heading>
135
+ {p?.environment === "test" && <Text className="txt-small text-ui-fg-muted">{t.testMode}</Text>}
136
+ </div>
137
+
138
+ <div className="flex flex-col gap-y-1">
139
+ <Text className="txt-small text-ui-fg-subtle">{t.totalToPay}</Text>
140
+ <Text className="text-2xl font-semibold text-ui-fg-base" data-testid="doit-total">{rupiah(total)}</Text>
141
+ {customerFee > 0 && <Text className="txt-small text-ui-fg-subtle">{t.includesFee(rupiah(customerFee))}</Text>}
142
+ </div>
143
+
144
+ {!done && !retry && state.qr_expired && p?.hosted_url && (
145
+ <div className="flex flex-col items-start gap-y-3" data-testid="doit-qr-expired">
146
+ <Text className="txt-medium text-ui-fg-base max-w-sm">{t.qrExpired}</Text>
147
+ <Button asChild variant="primary">
148
+ <a href={p.hosted_url}>{t.continueHosted}</a>
149
+ </Button>
150
+ </div>
151
+ )}
152
+
153
+ {!done && !retry && p?.qr_content && (
154
+ <div className="flex flex-col items-start gap-y-3">
155
+ <div className="rounded-rounded border border-ui-border-base bg-white p-3" data-testid="doit-qris">
156
+ <QRCodeSVG value={p.qr_content} size={220} level="M" marginSize={2} title="QRIS" />
157
+ </div>
158
+ <Text className="txt-small text-ui-fg-subtle max-w-sm">{t.scanQris}</Text>
159
+ </div>
160
+ )}
161
+
162
+ {!done && !retry && p?.va_number && (
163
+ <div className="flex flex-col gap-y-2" data-testid="doit-va">
164
+ <Text className="txt-small text-ui-fg-subtle">{t.vaNumber(bankNames[p.va_bank ?? ""] ?? (p.va_bank ?? "").toUpperCase())}</Text>
165
+ <div className="flex items-center gap-x-3">
166
+ <span className="font-mono text-2xl tracking-wider text-ui-fg-base" data-testid="doit-va-number">{p.va_number}</span>
167
+ <CopyButton value={p.va_number} />
168
+ </div>
169
+ <Text className="txt-small text-ui-fg-subtle max-w-sm">{t.vaHint}</Text>
170
+ </div>
171
+ )}
172
+
173
+ {!done && !retry && expiresAt !== null && (
174
+ <div className="flex items-baseline gap-x-2" data-testid="doit-countdown">
175
+ <Text className="txt-small text-ui-fg-subtle">{isExpired ? t.expired : t.expiresIn}</Text>
176
+ {!isExpired && (
177
+ <Text className="font-mono txt-medium-plus text-ui-fg-base" suppressHydrationWarning>
178
+ {remaining(expiresAt, now)}
179
+ </Text>
180
+ )}
181
+ </div>
182
+ )}
183
+
184
+ <div className={clx("flex items-center gap-x-2 txt-small", retry ? "text-ui-fg-error" : "text-ui-fg-subtle")} data-testid="doit-status">
185
+ {!done && !retry && <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-ui-fg-interactive" />}
186
+ <span>{done ? t.paid : retry ? t.failed : t.waiting}</span>
187
+ </div>
188
+
189
+ {error && <Text className="txt-small text-ui-fg-error">{error}</Text>}
190
+
191
+ <div className="flex flex-wrap items-center gap-3">
192
+ {retry && (
193
+ <Button onClick={newInvoice} isLoading={busy} data-testid="doit-new-invoice">{t.newInvoice}</Button>
194
+ )}
195
+ {!done && !retry && p?.hosted_url && (
196
+ <a href={p.hosted_url} target="_blank" rel="noopener noreferrer" className="txt-small text-ui-fg-interactive hover:text-ui-fg-interactive-hover underline" data-testid="doit-hosted-link">
197
+ {t.openHosted}
198
+ </a>
199
+ )}
200
+ </div>
201
+ </div>
202
+ )
203
+ }
204
+
205
+ function CopyButton({ value }: { value: string }) {
206
+ const [copied, setCopied] = useState(false)
207
+ const copy = async () => {
208
+ try {
209
+ await navigator.clipboard.writeText(value)
210
+ } catch {
211
+ // Older browsers / non-secure origins: fall back to a hidden textarea.
212
+ const el = document.createElement("textarea")
213
+ el.value = value
214
+ document.body.appendChild(el)
215
+ el.select()
216
+ document.execCommand("copy")
217
+ el.remove()
218
+ }
219
+ setCopied(true)
220
+ setTimeout(() => setCopied(false), 2000)
221
+ }
222
+ return (
223
+ <Button variant="secondary" size="small" onClick={copy} data-testid="doit-copy-va">
224
+ {copied ? t.copied : t.copy}
225
+ </Button>
226
+ )
227
+ }
@@ -0,0 +1,122 @@
1
+ "use client"
2
+
3
+ import { DoitChoice, DoitMethod, listDoitMethods } from "@lib/data/doit"
4
+ import { Text, clx } from "@medusajs/ui"
5
+ import { useEffect, useState } from "react"
6
+ import { rupiah } from "./format"
7
+ import { bankNames, doitLabels as t } from "./labels"
8
+
9
+ type Props = {
10
+ cartId: string
11
+ value: DoitChoice | null
12
+ onChange: (choice: DoitChoice) => void
13
+ }
14
+
15
+ const keyOf = (c: { rail: string; va_bank?: string | null; bank?: string | null }) =>
16
+ `${c.rail}:${c.va_bank ?? c.bank ?? ""}`
17
+
18
+ /**
19
+ * Lists the methods doit.id has active for this store (QRIS, VA banks,
20
+ * transfer banks) with the fee for this cart, and reports the choice.
21
+ */
22
+ export default function DoitRailPicker({ cartId, value, onChange }: Props) {
23
+ const [methods, setMethods] = useState<DoitMethod[] | null>(null)
24
+ const [unavailable, setUnavailable] = useState(false)
25
+
26
+ useEffect(() => {
27
+ let active = true
28
+ listDoitMethods(cartId).then((res) => {
29
+ if (!active) return
30
+ if (!res.ok || res.data.source !== "doit" || !res.data.methods.length) {
31
+ setUnavailable(true)
32
+ setMethods([])
33
+ if (res.ok && !value) {
34
+ onChange({ rail: res.data.default.rail, va_bank: res.data.default.va_bank })
35
+ }
36
+ return
37
+ }
38
+ setMethods(res.data.methods)
39
+ if (!value) {
40
+ const preferred =
41
+ res.data.methods.find((m) => m.within_limits !== false && keyOf(m) === keyOf(res.data.default)) ??
42
+ res.data.methods.find((m) => m.within_limits !== false) ??
43
+ res.data.methods[0]
44
+ onChange({ rail: preferred.rail, va_bank: preferred.bank })
45
+ }
46
+ })
47
+ return () => {
48
+ active = false
49
+ }
50
+ // eslint-disable-next-line react-hooks/exhaustive-deps
51
+ }, [cartId])
52
+
53
+ if (methods === null) {
54
+ return <Text className="txt-small text-ui-fg-subtle">{t.loading}</Text>
55
+ }
56
+ if (unavailable) {
57
+ return <Text className="txt-small text-ui-fg-subtle">{t.methodsUnavailable}</Text>
58
+ }
59
+
60
+ const groups: { title: string; items: DoitMethod[] }[] = [
61
+ { title: t.qris, items: methods.filter((m) => m.rail === "qris") },
62
+ { title: t.va, items: methods.filter((m) => m.rail === "va") },
63
+ { title: t.transfer, items: methods.filter((m) => m.rail === "transfer") },
64
+ ].filter((g) => g.items.length)
65
+
66
+ return (
67
+ <div className="flex flex-col gap-y-4" data-testid="doit-rail-picker">
68
+ <Text className="txt-medium-plus text-ui-fg-base">{t.chooseMethod}</Text>
69
+ {groups.map((group) => (
70
+ <fieldset key={group.title} className="flex flex-col gap-y-2">
71
+ {/* QRIS is a single option that already says "QRIS" */}
72
+ {group.items[0].rail !== "qris" && (
73
+ <legend className="txt-small text-ui-fg-subtle mb-1">{group.title}</legend>
74
+ )}
75
+ <div className="grid grid-cols-1 small:grid-cols-2 gap-2">
76
+ {group.items.map((m) => {
77
+ const selected = value ? keyOf(value) === keyOf(m) : false
78
+ const disabled = m.within_limits === false
79
+ const customerFee = m.fee_payer === "customer" && m.fee_amount ? m.fee_amount : 0
80
+ const fee = disabled ? t.outsideLimit(m.limit) : customerFee ? t.feeByCustomer(rupiah(customerFee)) : t.noFee
81
+ return (
82
+ <label
83
+ key={m.code}
84
+ className={clx(
85
+ "flex items-center justify-between gap-x-3 rounded-rounded border px-4 py-3",
86
+ disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:shadow-borders-interactive-with-active",
87
+ selected ? "border-ui-border-interactive" : "border-ui-border-base"
88
+ )}
89
+ data-testid={`doit-method-${m.code}`}
90
+ >
91
+ <span className="flex items-center gap-x-3">
92
+ <input
93
+ type="radio"
94
+ name="doit-method"
95
+ className="accent-ui-fg-interactive"
96
+ checked={selected}
97
+ disabled={disabled}
98
+ onChange={() => onChange({ rail: m.rail, va_bank: m.bank })}
99
+ />
100
+ <span className="flex flex-col">
101
+ <span className="txt-compact-medium text-ui-fg-base">
102
+ {m.rail === "qris" ? t.qris : bankNames[m.bank ?? ""] ?? m.label}
103
+ </span>
104
+ {m.rail === "qris" && <span className="txt-small text-ui-fg-subtle">{t.qrisHint}</span>}
105
+ </span>
106
+ </span>
107
+ <span className="flex flex-col items-end txt-small text-ui-fg-subtle whitespace-nowrap">
108
+ <span>{fee}</span>
109
+ {!disabled && customerFee > 0 && m.total_amount !== null && (
110
+ <span className="text-ui-fg-base">{t.totalWithFee(rupiah(m.total_amount))}</span>
111
+ )}
112
+ </span>
113
+ </label>
114
+ )
115
+ })}
116
+ </div>
117
+ </fieldset>
118
+ ))}
119
+ <Text className="txt-small text-ui-fg-muted">{t.feeNote}</Text>
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,123 @@
1
+ diff --git a/src/lib/constants.tsx b/src/lib/constants.tsx
2
+ index ce3bc60..0d609e8 100644
3
+ --- a/src/lib/constants.tsx
4
+ +++ b/src/lib/constants.tsx
5
+ @@ -34,6 +34,10 @@ export const paymentInfoMap: Record<
6
+ title: "Manual Payment",
7
+ icon: <CreditCard />,
8
+ },
9
+ + pp_doit_doit: {
10
+ + title: "QRIS / Virtual Account",
11
+ + icon: <CreditCard />,
12
+ + },
13
+ // Add more payment providers here
14
+ }
15
+
16
+ diff --git a/src/modules/checkout/components/payment-button/index.tsx b/src/modules/checkout/components/payment-button/index.tsx
17
+ index 04a83c9..6567904 100644
18
+ --- a/src/modules/checkout/components/payment-button/index.tsx
19
+ +++ b/src/modules/checkout/components/payment-button/index.tsx
20
+ @@ -2,6 +2,10 @@
21
+
22
+ import { isManual, isStripeLike } from "@lib/constants"
23
+ import { placeOrder } from "@lib/data/cart"
24
+ +import {
25
+ + DoitPaymentButton,
26
+ + isDoit,
27
+ +} from "@modules/checkout/components/doit-payment"
28
+ import { HttpTypes } from "@medusajs/types"
29
+ import { Button } from "@medusajs/ui"
30
+ import { useElements, useStripe } from "@stripe/react-stripe-js"
31
+ @@ -35,6 +39,8 @@ const PaymentButton: React.FC<PaymentButtonProps> = ({
32
+ data-testid={dataTestId}
33
+ />
34
+ )
35
+ + case isDoit(paymentSession?.provider_id):
36
+ + return <DoitPaymentButton cart={cart} notReady={notReady} />
37
+ case isManual(paymentSession?.provider_id):
38
+ return (
39
+ <ManualTestPaymentButton notReady={notReady} data-testid={dataTestId} />
40
+ diff --git a/src/modules/checkout/components/payment/index.tsx b/src/modules/checkout/components/payment/index.tsx
41
+ index d96beeb..e806b99 100644
42
+ --- a/src/modules/checkout/components/payment/index.tsx
43
+ +++ b/src/modules/checkout/components/payment/index.tsx
44
+ @@ -3,6 +3,7 @@
45
+ import { RadioGroup } from "@headlessui/react"
46
+ import { isStripeLike, paymentInfoMap } from "@lib/constants"
47
+ import { initiatePaymentSession } from "@lib/data/cart"
48
+ +import { DoitChoice, selectDoitMethod } from "@lib/data/doit"
49
+ import { CheckCircleSolid, CreditCard } from "@medusajs/icons"
50
+ import { Button, Container, Heading, Text, clx } from "@medusajs/ui"
51
+ import ErrorMessage from "@modules/checkout/components/error-message"
52
+ @@ -10,6 +11,10 @@ import PaymentContainer, {
53
+ StripeCardContainer,
54
+ } from "@modules/checkout/components/payment-container"
55
+ import Divider from "@modules/common/components/divider"
56
+ +import {
57
+ + DoitRailPicker,
58
+ + isDoit,
59
+ +} from "@modules/checkout/components/doit-payment"
60
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
61
+ import { useCallback, useEffect, useState } from "react"
62
+
63
+ @@ -32,6 +37,16 @@ const Payment = ({
64
+ activeSession?.provider_id ?? ""
65
+ )
66
+
67
+ + const [doitChoice, setDoitChoice] = useState<DoitChoice | null>(
68
+ + activeSession && isDoit(activeSession.provider_id)
69
+ + ? {
70
+ + rail: (activeSession.data?.requested_rail ??
71
+ + activeSession.data?.rail) as DoitChoice["rail"],
72
+ + va_bank: activeSession.data?.va_bank as string | null,
73
+ + }
74
+ + : null
75
+ + )
76
+ +
77
+ const searchParams = useSearchParams()
78
+ const router = useRouter()
79
+ const pathname = usePathname()
80
+ @@ -79,7 +94,18 @@ const Payment = ({
81
+ const checkActiveSession =
82
+ activeSession?.provider_id === selectedPaymentMethod
83
+
84
+ - if (!checkActiveSession) {
85
+ + if (isDoit(selectedPaymentMethod)) {
86
+ + // New doit.id invoice only when the method changed; back here after the hosted page.
87
+ + const res = await selectDoitMethod(
88
+ + cart,
89
+ + selectedPaymentMethod,
90
+ + doitChoice!,
91
+ + `${window.location.origin}${pathname}?step=review`
92
+ + )
93
+ + if (!res.ok) {
94
+ + throw new Error(res.message)
95
+ + }
96
+ + } else if (!checkActiveSession) {
97
+ await initiatePaymentSession(cart, {
98
+ provider_id: selectedPaymentMethod,
99
+ })
100
+ @@ -161,6 +187,15 @@ const Payment = ({
101
+ </div>
102
+ ))}
103
+ </RadioGroup>
104
+ + {isDoit(selectedPaymentMethod) && (
105
+ + <div className="mt-4">
106
+ + <DoitRailPicker
107
+ + cartId={cart.id}
108
+ + value={doitChoice}
109
+ + onChange={setDoitChoice}
110
+ + />
111
+ + </div>
112
+ + )}
113
+ </>
114
+ )}
115
+
116
+ @@ -190,6 +225,7 @@ const Payment = ({
117
+ isLoading={isLoading}
118
+ disabled={
119
+ (isStripeLike(selectedPaymentMethod) && !cardComplete) ||
120
+ + (isDoit(selectedPaymentMethod) && !doitChoice) ||
121
+ (!selectedPaymentMethod && !paidByGiftcard)
122
+ }
123
+ data-testid="submit-payment-button"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "medusa-payment-doit",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "doit.id payment gateway (QRIS, Virtual Account, bank transfer) for Medusa v2 — by Haus Kayu",
5
5
  "author": "Haus Kayu <dev@hauskayu.com> (https://hauskayu.com)",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@
15
15
  },
16
16
  "files": [
17
17
  ".medusa/server",
18
+ "examples/nextjs-starter",
18
19
  "CHANGELOG.md"
19
20
  ],
20
21
  "publishConfig": {
@@ -43,7 +44,9 @@
43
44
  "doit",
44
45
  "qris",
45
46
  "virtual-account",
46
- "indonesia"
47
+ "indonesia",
48
+ "medusa-plugin-integration",
49
+ "medusa-plugin-payment"
47
50
  ],
48
51
  "scripts": {
49
52
  "build": "medusa plugin:build",
@@ -85,14 +88,14 @@
85
88
  "yalc": "^1.0.0-pre.53"
86
89
  },
87
90
  "peerDependencies": {
88
- "@medusajs/admin-sdk": "2.19.0",
89
- "@medusajs/cli": "2.19.0",
90
- "@medusajs/framework": "2.19.0",
91
- "@medusajs/icons": "2.19.0",
92
- "@medusajs/js-sdk": "2.19.0",
93
- "@medusajs/medusa": "2.19.0",
94
- "@medusajs/test-utils": "2.19.0",
95
- "@medusajs/ui": "4.2.1"
91
+ "@medusajs/admin-sdk": "^2.19.0",
92
+ "@medusajs/cli": "^2.19.0",
93
+ "@medusajs/framework": "^2.19.0",
94
+ "@medusajs/icons": "^2.19.0",
95
+ "@medusajs/js-sdk": "^2.19.0",
96
+ "@medusajs/medusa": "^2.19.0",
97
+ "@medusajs/test-utils": "^2.19.0",
98
+ "@medusajs/ui": "^4.2.1"
96
99
  },
97
100
  "engines": {
98
101
  "node": "^20.19.0 || >=22.12.0"