Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 55 additions & 29 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@moneydevkit/api-contract": "^0.1.16",
"@moneydevkit/api-contract": "^0.1.17",
"@moneydevkit/lightning-js": "^0.1.60",
"@orpc/client": "1.3.0",
"@orpc/contract": "1.3.0",
Expand Down
28 changes: 14 additions & 14 deletions packages/core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,34 +132,34 @@ type AmountCheckoutParams = CommonCheckoutFields & {
amount: number
title?: string
description?: string
productId?: never
products?: never
product?: never
}

type ProductsCheckoutParams = CommonCheckoutFields & {
type ProductCheckoutParams = CommonCheckoutFields & {
type: 'PRODUCTS'
products: string[]
productId?: string
/**
* Product ID to checkout.
* @example 'prod_123abc'
*/
product: string
amount?: never
title?: never
description?: never
}

export type CreateCheckoutParams = AmountCheckoutParams | ProductsCheckoutParams
export type CreateCheckoutParams = AmountCheckoutParams | ProductCheckoutParams

export async function createCheckout(
params: CreateCheckoutParams
): Promise<Result<{ checkout: Checkout }>> {
const currency = params.currency ?? 'USD'
const metadataOverrides = params.metadata ?? {}

const isProductsCheckout = params.type === 'PRODUCTS'
const productIds = isProductsCheckout
? (params.productId ? [params.productId, ...params.products] : params.products)
: undefined
const amount = isProductsCheckout ? undefined : params.amount
const title = isProductsCheckout ? undefined : params.title
const description = isProductsCheckout ? undefined : params.description
const isProductCheckout = params.type === 'PRODUCTS'
const product = isProductCheckout ? params.product : undefined
const amount = isProductCheckout ? undefined : params.amount
const title = isProductCheckout ? undefined : params.title
const description = isProductCheckout ? undefined : params.description

try {
const client = createMoneyDevKitClient()
Expand All @@ -168,7 +168,7 @@ export async function createCheckout(
{
amount,
currency,
products: productIds,
product,
successUrl: params.successUrl,
metadata: {
title,
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/components/checkout/UnconfirmedCheckout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import type { Checkout } from '@moneydevkit/api-contract'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { clientConfirmCheckout } from '../../client-actions'
Expand Down Expand Up @@ -49,6 +50,21 @@ export default function UnconfirmedCheckout({ checkout }: UnconfirmedCheckoutPro
const queryClient = useQueryClient()
const missingFields = getMissingRequiredFields(checkout)

// Track selected product for confirm call and CUSTOM price handling.
// Setter intentionally omitted - single product only for now (multi-product selection coming later)
const [selectedProductId] = useState<string | null>(
checkout.products?.[0]?.id ?? null
)

// For CUSTOM price types, track the user-entered amount
const [customAmount, setCustomAmount] = useState<string>('')
const [customAmountError, setCustomAmountError] = useState<string | null>(null)

// Get the selected product and check if it has a CUSTOM price
const selectedProduct = checkout.products?.find((p) => p.id === selectedProductId)
const selectedPrice = selectedProduct?.prices?.[0]
const isCustomPrice = selectedPrice?.amountType === 'CUSTOM'

// Build dynamic schema based on missing required fields
const schemaShape: Record<string, z.ZodTypeAny> = {}
for (const field of missingFields) {
Expand All @@ -74,9 +90,32 @@ export default function UnconfirmedCheckout({ checkout }: UnconfirmedCheckoutPro

const confirmMutation = useMutation({
mutationFn: async (data: CustomerFormData) => {
// For product checkouts, include the selected product
const productId = selectedProductId ?? checkout.products?.[0]?.id

// Build the products payload
let productsPayload: { productId: string; priceAmount?: number }[] | undefined
if (checkout.type === 'PRODUCTS' && productId) {
const product: { productId: string; priceAmount?: number } = { productId }

// If CUSTOM price, include the user-entered amount
// USD: convert dollars to cents (multiply by 100, with EPSILON for float precision)
// SAT: use directly (amounts are in sats)
if (isCustomPrice && customAmount) {
const parsedAmount = Number.parseFloat(customAmount)
const amountInSmallestUnit = checkout.currency === 'USD'
? Math.round(parsedAmount * 100 + Number.EPSILON)
: Math.round(parsedAmount)
product.priceAmount = amountInSmallestUnit
}

productsPayload = [product]
}

return await clientConfirmCheckout({
checkoutId: checkout.id,
customer: data,
...(productsPayload ? { products: productsPayload } : {}),
})
},
onSuccess: () => {
Expand All @@ -90,6 +129,19 @@ export default function UnconfirmedCheckout({ checkout }: UnconfirmedCheckoutPro
})

const onSubmit = (data: CustomerFormData) => {
// Validate custom amount if required
if (isCustomPrice) {
const amount = Number.parseFloat(customAmount)
const minAmount = checkout.currency === 'SAT' ? 1 : 0.01
if (!customAmount || Number.isNaN(amount) || amount < minAmount) {
setCustomAmountError(checkout.currency === 'SAT'
? 'Please enter at least 1 sat'
: 'Please enter a valid amount')
return
}
setCustomAmountError(null)
}

confirmMutation.mutate(data)
}

Expand All @@ -114,6 +166,9 @@ export default function UnconfirmedCheckout({ checkout }: UnconfirmedCheckoutPro
{product.prices[0].amountType === 'FIXED' && product.prices[0].priceAmount && (
<span>{formatCurrency(product.prices[0].priceAmount, checkout.currency)}</span>
)}
{product.prices[0].amountType === 'CUSTOM' && (
<span className="text-gray-400">Pay what you want</span>
)}
{product.recurringInterval && (
<span className="text-gray-400">
/{product.recurringInterval.toLowerCase()}
Expand All @@ -131,6 +186,39 @@ export default function UnconfirmedCheckout({ checkout }: UnconfirmedCheckoutPro
{checkout.type === 'TOP_UP' && <div className="text-lg text-white">Account Top-up</div>}
</div>

{/* Custom price amount input */}
{isCustomPrice && (
<div className="mb-4">
<label htmlFor="customAmount" className="block text-sm font-medium text-gray-300 mb-2">
Enter Amount ({checkout.currency})
</label>
<div className="relative">
{checkout.currency === 'USD' && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
)}
<Input
id="customAmount"
type="number"
min={checkout.currency === 'SAT' ? '1' : '0.01'}
step={checkout.currency === 'SAT' ? '1' : '0.01'}
value={customAmount}
onChange={(e) => {
setCustomAmount(e.target.value)
setCustomAmountError(null)
}}
placeholder={checkout.currency === 'SAT' ? '1000' : '0.00'}
className={`bg-gray-700 border-gray-600 focus:ring-purple-500 focus:border-purple-500 text-white placeholder-gray-400 ${checkout.currency === 'USD' ? 'pl-8' : 'pr-12'}`}
/>
{checkout.currency === 'SAT' && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400">sats</span>
)}
</div>
{customAmountError && (
<p className="text-red-400 text-sm mt-1">{customAmountError}</p>
)}
</div>
)}

<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{missingFields.map((field) => (
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/handlers/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,24 @@ const amountCheckoutSchema = z.object({
...commonCheckoutFields,
})

const productsCheckoutSchema = z.object({
const productCheckoutSchema = z.object({
type: z.literal('PRODUCTS'),
products: z.array(z.string()),
productId: z.string().optional(),
product: z.string(),
...commonCheckoutFields,
})

const createCheckoutSchema = z.discriminatedUnion('type', [
amountCheckoutSchema,
productsCheckoutSchema,
productCheckoutSchema,
])

const confirmCheckoutSchema = z.object({
checkoutId: z.string(),
customer: customerInputSchema.optional(),
products: z.array(z.object({
productId: z.string(),
priceAmount: z.number().optional(),
})).max(1).optional(),
})

function jsonResponse(status: number, body: Record<string, unknown>) {
Expand Down
18 changes: 16 additions & 2 deletions packages/core/src/mdk-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ export type MoneyDevKitClientOptions = {
baseUrl: string
}

/**
* SDK-level checkout creation options.
* Uses `product` (singular) for simplicity - multi-product support coming soon.
*/
export type CreateCheckoutOptions = Omit<CreateCheckout, 'nodeId' | 'products'> & {
/**
* Product ID to include in this checkout.
* @example 'prod_123abc'
*/
product?: string
}

export class MoneyDevKitClient {
private client: ContractRouterClient<typeof contract>

Expand All @@ -29,11 +41,13 @@ export class MoneyDevKitClient {
return await this.client.checkout.get(params)
},
create: async (
fields: Omit<CreateCheckout, 'nodeId'>,
fields: CreateCheckoutOptions,
nodeId: string,
): Promise<Checkout> => {
const { product, ...rest } = fields
return await this.client.checkout.create({
...fields,
...rest,
products: product ? [product] : undefined,
nodeId,
})
},
Expand Down
2 changes: 1 addition & 1 deletion packages/create/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"@moneydevkit/api-contract": "^0.1.16",
"@moneydevkit/api-contract": "^0.1.17",
"@moneydevkit/lightning-js": "^0.1.60",
"@orpc/client": "^1.3.0",
"@orpc/contract": "^1.3.0",
Expand Down
14 changes: 14 additions & 0 deletions packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ function ProductPage() {
- **`type: 'AMOUNT'`** - For donations, tips, or custom amounts. Requires `amount` field.
- **`type: 'PRODUCTS'`** - For selling products. Requires `products` array with product IDs. Amount is calculated from product prices.

### Pay What You Want (CUSTOM prices)
Products can have CUSTOM prices that let customers choose their own amount. When a checkout includes a product with a CUSTOM price, the checkout UI automatically shows an amount input field:

```jsx
// Create a checkout for a product with CUSTOM pricing
const result = await createCheckout({
type: 'PRODUCTS',
products: [customPriceProductId], // Product configured with CUSTOM price in dashboard
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

product? not products?

successUrl: '/checkout/success',
})
```

The customer enters their desired amount during checkout. For USD, amounts are in dollars (converted to cents internally). For SAT, amounts are in satoshis.

## Verify successful payments
When a checkout completes, use `useCheckoutSuccess()` on the success page
```tsx
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@moneydevkit/api-contract": "^0.1.16",
"@moneydevkit/api-contract": "^0.1.17",
"@moneydevkit/core": "0.7.0-beta.8",
"@moneydevkit/lightning-js": "^0.1.60",
"@orpc/client": "1.3.0",
Expand Down
14 changes: 14 additions & 0 deletions packages/replit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,20 @@ function ProductPage() {
- **`type: 'AMOUNT'`** - For donations, tips, or custom amounts. Requires `amount` field.
- **`type: 'PRODUCTS'`** - For selling products. Requires `products` array with product IDs. Amount is calculated from product prices.

### Pay What You Want (CUSTOM prices)
Products can have CUSTOM prices that let customers choose their own amount. When a checkout includes a product with a CUSTOM price, the checkout UI automatically shows an amount input field:

```tsx
// Create a checkout for a product with CUSTOM pricing
const result = await createCheckout({
type: 'PRODUCTS',
products: [customPriceProductId], // Product configured with CUSTOM price in dashboard
successUrl: '/checkout/success',
})
```

The customer enters their desired amount during checkout. For USD, amounts are in dollars (converted to cents internally). For SAT, amounts are in satoshis.

## Verify successful payments
```tsx
import { useCheckoutSuccess } from '@moneydevkit/replit'
Expand Down