DEV Community

Cover image for Making PayPal refuse the wrong charge: a scooter deposit as one authorization
Edy Cu
Edy Cu

Posted on

Making PayPal refuse the wrong charge: a scooter deposit as one authorization

In Bali the deposit on a rented scooter is often your passport. At return the shop points at a dent, you say it was already there, and whoever is holding the document wins the argument.

I built Jaminan for the PayPal AI Hackathon to replace the passport with something both sides can check: a PayPal authorization that can only pay for lines the renter accepted. The part worth writing about is not the AI. It is how little code it takes to make PayPal itself refuse the wrong settlement, and the places the sandbox disagreed with my first design.

Everything below ran on the PayPal sandbox. Code: https://github.com/edycutjong/jaminan. Live: https://jaminan.edycu.dev/app/judge (one tap, fresh sandbox rental). Demo video (2:56): https://youtu.be/E8r-QMLocQo. Devpost: https://devpost.com/software/jaminan.

The deposit is an authorization, not a charge

One renter approval creates an Orders v2 order with intent: AUTHORIZE for fee plus deposit ($115.00 in the demo: $15.00 + $100.00), with store_in_vault so the wallet can be charged later for fines. The fee is captured right away. The deposit is never captured unless something goes wrong:

def capture(self, authorization_id, amount_cents, final_capture, invoice_id, note, ...):
    body = {"amount": money(amount_cents), "final_capture": final_capture,
            "invoice_id": invoice_id[:127], "note_to_payer": note[:255]}
    return self.call("POST", f"/v2/payments/authorizations/{authorization_id}/capture", ...)
Enter fullscreen mode Exit fullscreen mode

The fee capture goes out with final_capture=false. PayPal reads the authorization back as PARTIALLY_CAPTURED, which means the remaining $100.00 is still held.

final_capture=true is the whole settlement

When the renter accepts a damage line, the settlement is one capture for exactly that amount with final_capture=true. That single flag does two jobs: it takes the accepted amount and releases everything else on the authorization. A clean return is a plain void.

In the recorded sandbox run for an accepted $18.00 mirror crack, PayPal reads the authorization back as CAPTURED and $82.00 of the deposit is released. Then the script tries to capture $1.00 more:

POST /v2/payments/authorizations/59Y22025SE889191E/capture   amount 1.00
HTTP 422  AUTHORIZATION_ALREADY_CAPTURED   (debug f5799404eea63)
Enter fullscreen mode Exit fullscreen mode

That refusal is the feature. A shop cannot quietly charge a second "scratch" after the renter has walked away, and I did not write a line of code to stop it; I only had to set one flag. The three recorded settlements (accepted, clean, contested) are in evidence/paypal-sandbox-run.json with every PayPal debug id.

The model never sets a price

The vision model only answers narrow questions about the shared pickup photos: is there a mark here now, is it the kind the shop named, was it visible at pickup, can the pickup photo show that spot. A plain function turns the answers into "cannot be charged" or a tariff line, and a second function builds the settlement:

capture = min(total_accepted, deposit_cents)
if fee_captured_cents + capture > authorized_cents:   # PayPal enforces too; we check first
    raise SettleBlocked("captures would exceed the authorized amount (I2)")
...
overage = max(total_accepted - deposit_cents, 0)
Enter fullscreen mode Exit fullscreen mode

Accepted lines above the deposit leave as an itemized PayPal invoice instead of a bigger capture. Contested lines are never captured at all; they go out as an invoice at the published tariff price, and the hold is voided.

Because the rule that matters ("a chargeable line is new since pickup and has a tariff price") is a pure function, I could test it exhaustively: 23,040 combinations of the model's yes/no answers, serving model, confidence, a confirmed baseline mark, comparability, parts and damage classes. Every chargeable result satisfies every "new since pickup" condition. A Hypothesis property test generates up to 3,000 settlements and checks the hold never pays more than the accepted lines (plus an invoice for any amount above the deposit) and never captures more than the authorization.

Where the sandbox disagreed with my design

  • One vault token per buyer and merchant. I planned a pool of judge wallets. PayPal returns the same vault token for the same buyer and merchant, so the pool holds one token until I create more sandbox accounts.
  • No cards. The card-path authorize answered 422 without a stated reason (I believe advanced card processing is not enabled for my Indonesia-region sandbox merchant), so Jaminan is wallet-only.
  • Reauthorize is date-gated. PayPal allows it only from Day 4 of an authorization. I built and unit-tested the path and its refusal fallback, then waited. On day 9 the sandbox reauthorized two holds, one with its fee already captured, and refused a second attempt on the same hold with 422: "only allowed once from Day 4 to Day 29".
  • A stale read-back. My own live check caught the /judge page showing PARTIALLY_CAPTURED after a release, because I cached the read-back on the authorization id alone. The cache key now includes the rental state, and there is a regression test. A settle screen that stamps "released" should be stamping what PayPal says now.

What it does not do yet

Be careful with the AI claim. No vision model is benchmarked yet, because the staged scooter photo set is still to be shot and scored. Until a model is pinned, a live claim ends "comparison unavailable — nothing charged from the hold", so the live app can release a hold but cannot price a damage line. The priced path is covered by tests and by the recorded run (with scripted claim answers, labelled as scripted).

I did run two smaller checks, and the model failed both, which is exactly why nothing is pinned:

  • A partial check on public photos. 48 Wikimedia Commons photos (26 damaged with 37 labelled marks, 22 clean scooters; CC BY, CC BY-SA and CC0 only), each used as both the pickup and the return photo, so the only right answer was "old" or "not new". The labels were drawn by my coding agent (Claude), not an independent annotator, and committed before any model call. qwen3-vl-plus with the app's prompts called an old mark new in 71 of 105 answers and a clean spot new in 66 of 66. The bar is 0. It is not the staged-set benchmark, and it cannot test new damage between two photos.
  • A pair of AI-generated test images (not photographs). A pickup image and an edit with both mirrors removed. The model read both pointed claims wrong, three times each. Had that model been pinned without the bench, the rules would have priced an old scuff at $45.00.

That is the design working as intended: a model that cannot tell old damage from new never gets to move money.

Also: sandbox only, one merchant, and non-return (a scooter that never comes back) is out of scope.

Agents can book, but not pay

A small MCP server (4 tools) lets a booking agent such as Claude Code or Codex quote and start a rental. Only the renter can approve the hold on PayPal's own page; the agent's token gets 403 on all 8 money and answer routes. Both agents' bookings and the 403s are in evidence/mcp/.

Try it

Open https://jaminan.edycu.dev/app/judge, tap Start a judge rental, then Release the hold, and watch /integrations/verify list every PayPal call with its status. Or run the offline suite (234 tests, no credentials, 100% line and branch coverage) from the repo; the full 243-test run includes 8 against the real sandbox.

If you work on rental, marketplace or escrow-style flows, the thing to steal is small: model the deposit as an authorization, and let final_capture=true be the only way money leaves it.

Top comments (1)

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

The part I would steal is exactly that final_capture=true is the settlement, not just a flag: $18.00 taken, $82.00 released, and a second $1.00 capture refused with a 422, so the shop cannot add a scratch after the renter walks away. That turns PayPal into the enforcer instead of a receipt printer. I would still separate a network retry from a new charge in my own code, because a retried final capture would also hit that refusal. Do you check the authorization status before retrying a capture, or rely on the invoice id to tell a retry apart?