# Dollars and rupees without Stripe: what building Skill Exchange's checkout taught me (PayPal + UPI)

> Source: <https://dev.to/mohanvenkatakrishnan/dollars-and-rupees-without-stripe-what-building-skill-exchanges-checkout-taught-me-paypal-upi-3i8p>
> Published: 2026-08-02 19:45:22+00:00

"Just add Stripe" is the default advice for a solo dev who needs to charge money. It works right up until you're an Indian merchant trying to sell to a buyer in San Francisco *and* a buyer in Bangalore in the same afternoon — at which point you discover that a single provider can't actually do both, and the fix rearranges more of your code than you'd expect.

I hit this building Skill Exchange (a marketplace for reusable AI skills — link at the end). The marketplace itself isn't the point here; it's the harness I used to figure out how to take money from two people on opposite sides of the world without a US entity and without Stripe. These are the things I'd tell another dev before they start.

The first assumption to kill: PayPal is not a domestic payment method in India. PayPal shut down India-to-India domestic payments in April 2021. What's left works *only* cross-border — an Indian merchant can receive money from abroad, but a rupee-holding buyer in India cannot pay an India-based merchant through it.

I learned this the way you'd expect: I wired up PayPal, tested it with my own (Indian) account against my own (Indian) merchant account, and got a cheerful, useless *"Things don't appear to be working at the moment."* No error code, no docs entry — the transaction is simply classified as domestic and refused. It works flawlessly the moment the buyer is international, which is exactly what makes the failure so confusing when you're testing from your own country.

Takeaway: if any of your buyers are in the same country as your PayPal merchant account, PayPal alone will strand them. You need a second rail.

So the architecture is two providers, chosen by the buyer's currency:

The important design decision is that *the buyer* chooses the rail, at checkout, by choosing a currency. I default the toggle by timezone and let them override it — no IP lookup, no external call:

``` js
const defaultCurrency = (() => {
  try {
    const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
    return tz === "Asia/Kolkata" || tz === "Asia/Calcutta" ? "INR" : "USD";
  } catch { return "USD"; }
})();
```

A timezone heuristic is wrong for NRIs and travellers — which is exactly why it's only a *default*, never a lock. The toggle is always there.

Then the `buy`

endpoint routes on that one field:

``` js
async function buySkill(user, skillId, body) {
  const skill = await getSkill(skillId);
  const wantsInr = String(body?.currency).toUpperCase() === "INR";

  if (wantsInr) {
    const paise = usdCentsToInrPaise(skill.priceCents);
    const order = await razorpay.createOrder({ amount: paise, currency: "INR",
      notes: { skillId, buyerId: user.id } });        // notes bind it to this buyer
    return { provider: "razorpay", orderId: order.id, amount: paise, currency: "INR" };
  }
  const order = await paypal.createOrder({ amountUsd: skill.priceCents / 100,
    skillId, buyerId: user.id });                     // custom_id binds it
  return { provider: "paypal", orderId: order.id, amount: skill.priceCents, currency: "USD" };
}
```

The naive move is to convert the USD price at some rate and show the result. Don't. A $12 skill at ₹86/\$ is ₹1,032, which looks like a bug and *feels* expensive — Indian willingness-to-pay for digital goods is lower than a dollar conversion implies. Every serious seller into India uses regional price points instead.

So the conversion isn't `usd * rate`

— it rounds to a familiar ₹x99:

``` js
const INR_RATE = 86; // one knob; change it to re-price the whole catalogue

function usdCentsToInrPaise(usdCents) {
  if (!usdCents) return 0;
  const rupees = (usdCents / 100) * INR_RATE;
  const clean = Math.max(1, Math.round(rupees / 100) * 100 - 1); // → ₹x99
  return clean * 100; // paise
}
// $5→₹399  $6→₹499  $9→₹799  $12→₹999  $15→₹1,299
```

That single `INR_RATE`

constant re-prices 200+ listings at once, and the rounding doubles as a light purchasing-power discount without any per-item work.

This is the one that bites later if you skip it. The moment you have more than one currency (and a platform fee), you cannot derive money after the fact. Two rules:

`amountCents`

is ambiguous the second some of them are paise.

```
purchase = {
  skillId, buyerId,
  amount: chargedMinorUnits,        // cents for USD, paise for INR
  currency,                         // "USD" | "INR"  — never omit
  commission: Math.round(chargedMinorUnits * 0.05), // frozen at sale time
  provider, providerPaymentId, purchasedAt: now,
};
```

And keep the seller-revenue aggregates *per currency* — summing cents and paise into one integer is how you produce a beautiful, meaningless number.

Both rails let the browser lie to you, so the confirm step trusts neither the amount nor the "it succeeded" flag from the client. It re-derives the truth from the provider and checks it belongs to this exact buyer + item:

`status === "COMPLETED"`

, and check the `custom_id`

you set at creation equals `skillId|buyerId`

. A captured order can't be replayed against a different purchase.`notes`

match the skill and buyer you created it for — the order amount/currency you record come from that fetch, not from the request body.

```
// Razorpay confirm
if (!verifyCheckoutSignature({ orderId, paymentId, signature })) return forbid();
const order = await razorpay.fetchOrder(orderId);            // source of truth
if (order.notes.skillId !== skillId || order.notes.buyerId !== user.id) return forbid();
await recordPurchase({ ...order fields..., currency: order.currency, amount: order.amount });
```

The rule is the same on both: the amount and currency you write to your ledger come from the provider's record of the order *you* opened, cryptographically tied to the buyer — never from the client that's telling you it paid.

None of this is exotic — it's just the stuff nobody mentions when the answer is "add Stripe," and all of it is invisible until the first "Things don't appear to be working" from your own test account.

*I built this while making Skill Exchange — a marketplace for reusable AI skills where every listing has to prove it works. Happy to answer payments questions in the comments.*
