Developers

Payments and payouts

How a buyer's money gets to the seller: paid to the seller's own PayPal, held there until the item arrives, with resell.store's fee taken at the source. This project runs on PayPal's sandbox.

The model

resell.store is a PayPal multiparty platform. Each seller connects their own PayPal account. When a buyer pays, the order is made out to the seller (payee.merchant_id), with resell.store's fee written into it (platform_fees) and the seller's share held by PayPal (disbursement_mode: DELAYED). resell.store never holds the money itself; it only tells PayPal when to let it go.

FileWhat it does
lib/server/paypal.tsThe REST client: token, onboarding, orders, capture, release, refund, webhook check.
lib/server/paypal-sellers.tsConnecting a seller, syncing their account, readiness.
lib/server/commerce.tsCheckout, placing the order, offers, shipping, releasing.
lib/server/disputes.tsCancelling, problems and refunds.
lib/server/payout-policy.tsThe fee and every timer.
lib/server/paypal-webhook.tsWhat PayPal tells us after the fact.
lib/server/sweeps.ts, workflows/main.tsThe timed side: cancellations and releases, retried.

A sale, step by step

  1. Connect the seller. On Connections (/tools/connections) the seller gets a one-time PayPal link from Partner Referrals (POST /v2/customer/partner-referrals), asking for PAYMENT, REFUND, PARTNER_FEE and DELAY_FUNDS_DISBURSEMENT. The tracking id is our user id. Back on resell.store, syncPayPalAccount asks PayPal who signed up under that tracking id, never trusting the return URL's query string, and saves paypal_account. readiness() is "ready" when their email is confirmed, payments are receivable, and they granted partnerfee and delay-funds-disbursement.
  2. Start checkout. startCheckout prices it (the listing, or an accepted offer, plus shipping), finds the payee, works out the fee and creates the PayPal order. A checkout row remembers what the buyer chose and the exact amounts. The buyer goes to PayPal's page: the login page for PayPal, the guest card form for a card. Nothing is charged yet.
  3. Buyer approves. PayPal sends them to /api/paypal/checkout/return?token={orderId}.
  4. Capture inside the order. finishCheckout calls placeOrder, which locks the listing row, prices it again, and refuses if anything moved since they left ("The price changed while you were in PayPal"). Only then does it capture, inside the same transaction, and write the order with PayPal's fee and the seller's net from the capture's seller_receivable_breakdown. The listing is marked sold. If the capture took money but the order can't be written, the money is refunded straight away.
  5. Hold. The order is paid. The seller ships within 3 days and marks it shipped. The money stays with PayPal.
  6. Release. When the buyer taps "It's all good" (confirmReceived), or when the order sweep finds 13 days have passed since shipping (releaseDue in a workflow task), the order completes and releaseOrder asks PayPal to pay the held capture out with a referenced payout (POST /v1/payments/referenced-payouts-items). Never while a problem is open.
  7. Or refund. Before release, refunds go back through PayPal as the seller (giveBack in disputes.ts), in full or in part.
The order PayPal sees
POST /v2/checkout/orders
PayPal-Request-Id: order-{checkoutId}
PayPal-Partner-Attribution-Id: {PAYPAL_BN_CODE}

{
  "intent": "CAPTURE",
  "purchase_units": [{
    "reference_id": "{checkoutId}",
    "amount": { "currency_code": "USD", "value": "109.00",
      "breakdown": { "item_total": { "value": "100.00" }, "shipping": { "value": "9.00" } } },
    "items": [{ "name": "Yellow dutch oven, 5.5 qt", "quantity": "1", "category": "PHYSICAL_GOODS", … }],
    "payee": { "merchant_id": "{seller's PayPal merchant id}" },
    "payment_instruction": {
      "disbursement_mode": "DELAYED",
      "platform_fees": [{ "amount": { "currency_code": "USD", "value": "10.00" } }]
    }
  }],
  "payment_source": { "paypal": { "experience_context": {
    "user_action": "PAY_NOW", "shipping_preference": "NO_SHIPPING",
    "landing_page": "GUEST_CHECKOUT", "return_url": "…/api/paypal/checkout/return", … } } }
}

A worked example

Jess buys a $100 item with $9 tracked shipping. These are the numbers from a sandbox capture; PayPal's own fee is whatever PayPal reports in the capture, so treat it as an example.

AmountWhere it comes from
Jess pays$109.00Item plus shipping
resell.store's fee$10.0010% of the item price (PLATFORM_FEE_BPS, default 1000). Shipping is never charged a fee.
PayPal's fee$4.29From the seller's share, as PayPal reports it on capture
Seller gets$94.71Held until release, then paid to their PayPal

One item, one buyer

Every listing is a single item. placeOrder selects the listing FOR UPDATE, so two buyers paying at the same moment queue: the second finds it sold ("Sorry, someone just bought this one") and is never captured. In the same transaction the buyer's offer is marked paid and every other open, countered or accepted offer on it is declined. Paused shops and your own listings can't be bought.

Safe to retry

Every PayPal call that moves money carries a PayPal-Request-Id. The same id gets the same result from PayPal, so a retried task, a refreshed return page or a doubled click never charges, pays or refunds twice.

CallRequest id
Create orderorder-{checkoutId}
Capturecapture-{checkoutId}
Releaserelease-{captureId}, then release-{captureId}-{n} after PayPal reports a failure
Cancel refundcancel-{orderId}
Part refundpartial-{disputeId}
Full refund for a problemrefund-{disputeId}

finishCheckout is safe to run twice too: a completed checkout returns the same order.

Timings

From lib/server/payout-policy.ts. PAYOUT_DEMO_MINUTES_PER_DAY shrinks a "day" to that many minutes so the auto-release can be shown live; leave it unset in production.

No carrier tracking yet

Orders have a delivered status and a deliveredAt date, but nothing sets them from a carrier: there's no tracking service wired in. So an order completes in one of two ways. The buyer confirms it arrived, or the order sweep assumes delivery 10 days after shipping and releases 3 days after that, never later than 27 days after payment.
RuleValue
Ship within (then the buyer may cancel)3 days after payment
Not shipped: cancelled and refunded7 days after payment
"Did it arrive?" check7 days after shipping
Assumed delivered10 days after shipping
Buyer's check window after that3 days, so released 13 days after shipping
Latest release27 days after payment: PayPal's 28-day hold, less a day for the sweep
Seller silent on a problem: resell.store steps in3 days
Offers, and accepted offers waiting for payment48 hours

Refunds and problems

  • The seller can cancel any time before shipping; the buyer once the ship-by date passes; the sweep after 7 days. Everything goes back.
  • After shipping, the buyer reports a problem. The money stays held while it's open or escalated.
  • The seller can refund in full, offer part back, or reply. Either side can ask resell.store to step in, and resolveDispute decides: refund, or release.
  • Refunds use PayPal-Auth-Assertion, an unsigned JWT naming the platform and the seller, because the money is the seller's. Once released, it can't be refunded here.
  • Disputes opened in PayPal come in through the webhook and follow PayPal's outcome; PayPal moves that money itself.

The PayPal webhook

POST /api/paypal/webhook checks each delivery with PayPal (verify-webhook-signature, with PAYPAL_WEBHOOK_ID) before believing it, and answers 401 if it doesn't check out. Each event is stored by PayPal's id in paypal_event first, so a redelivery is a no-op, and each handler only moves an order forward. A failure answers 500 so PayPal tries again. The events handled are listed on PayPal.

The sandbox demo seller

Seeded shops have no PayPal of their own. In the sandbox, payeeFor falls back to one shared demo seller (PAYPAL_DEMO_SELLER_ID) when the shop owner's account can't take payments, and Connections offers to link a test account to it instead of signing up. Connect it once with scripts/paypal-demo-seller.mjs, which prints the merchant id. demoSellerId() returns null when PAYPAL_ENV=live, so this never happens with real money.

Without PayPal keys

paypalEnabled() needs PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET and PAYPAL_PARTNER_MERCHANT_ID. Without them checkout is a test checkout: placeOrder runs with a mock payment, the order, lock and emails are all real, and nothing moves. Refunds on mock orders are recorded on paper, releases are skipped, Connections says PayPal isn't set up, and the webhook answers 404.

Sandbox only

This project has never been approved as a live PayPal partner. Everything above runs against api-m.sandbox.paypal.com. Going live needs PayPal's partner approval first.