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 +19 -1
- package/README.md +9 -7
- package/examples/nextjs-starter/README.md +109 -0
- package/examples/nextjs-starter/src/lib/data/doit.ts +140 -0
- package/examples/nextjs-starter/src/modules/checkout/components/doit-payment/format.ts +22 -0
- package/examples/nextjs-starter/src/modules/checkout/components/doit-payment/index.tsx +40 -0
- package/examples/nextjs-starter/src/modules/checkout/components/doit-payment/labels.ts +55 -0
- package/examples/nextjs-starter/src/modules/checkout/components/doit-payment/payment-panel.tsx +227 -0
- package/examples/nextjs-starter/src/modules/checkout/components/doit-payment/rail-picker.tsx +122 -0
- package/examples/nextjs-starter/starter.patch +123 -0
- package/package.json +13 -10
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.
|
|
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
|
[](https://www.npmjs.com/package/medusa-payment-doit)
|
|
7
7
|
[](https://github.com/haustex/pg-medusa-doit/actions/workflows/ci.yml)
|
|
8
|
-
[](https://medusajs.com)
|
|
8
|
+
[](https://medusajs.com)
|
|
9
9
|
[](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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
|  |  |
|
|
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
|
+
}
|
package/examples/nextjs-starter/src/modules/checkout/components/doit-payment/payment-panel.tsx
ADDED
|
@@ -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.
|
|
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"
|