cd /news/developer-tools/dollars-and-rupees-without-stripe-wh… · home topics developer-tools article
[ARTICLE · art-83929] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

A developer building Skill Exchange, a marketplace for reusable AI skills, found that PayPal cannot handle domestic payments in India, so they built a dual-provider checkout that routes Indian buyers to Razorpay and international buyers to PayPal. The system uses a timezone-based default currency toggle and regional pricing that rounds to ₹x99, with a single INR_RATE constant re-pricing the entire catalogue.

read5 min views1 publishedAug 2, 2026

"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:

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:

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:

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @skill exchange 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/dollars-and-rupees-w…] indexed:0 read:5min 2026-08-02 ·