Skip to content

Commit

Permalink
Fix confirmCardPayment in localstripe-v3.js and add an example
Browse files Browse the repository at this point in the history
I wanted to add an example of using localstripe-v3.js, as a way of testing
adrienverge#240 and also because it seems
generally useful as documentation.

Along the way I discovered confirmCardPayment isn't quite right; it skips
/confirm and goes right to /_authenticate. This fails on cards that don't
require 3D Secure authentication because the /_authenticate endpoint is
confused about why it's being called at all. I fixed this, modeled on how
confirmCardSetup works.

This in turn required a small fix to the localstripe backend to allow calls to
/confirm from the browser (i.e., with a client_secret and key as form data).
  • Loading branch information
Ben Creech committed Nov 9, 2024
1 parent 6b0ff20 commit 35b205d
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 10 deletions.
3 changes: 3 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ JavaScript source in the web page before it creates card elements:
<script src="http://localhost:8420/js.stripe.com/v3/"></script>
See the ``samples/pm_setup`` directory for an example of this with a
very minimalistic Stripe elements application.
Use webhooks
------------
Expand Down
39 changes: 30 additions & 9 deletions localstripe/localstripe-v3.js
Original file line number Diff line number Diff line change
Expand Up @@ -340,24 +340,45 @@ Stripe = (apiKey) => {
confirmCardPayment: async (clientSecret, data) => {
console.log('localstripe: Stripe().confirmCardPayment()');
try {
const success = await openModal(
'3D Secure\nDo you want to confirm or cancel?',
'Complete authentication', 'Fail authentication');
const pi = clientSecret.match(/^(pi_\w+)_secret_/)[1];
const url = `${LOCALSTRIPE_BASE_API}/v1/payment_intents/${pi}` +
`/_authenticate?success=${success}`;
const response = await fetch(url, {
let url = `${LOCALSTRIPE_BASE_API}/v1/payment_intents/${pi}/confirm`;
let response = await fetch(url, {
method: 'POST',
body: JSON.stringify({
body: new URLSearchParams({
key: apiKey,
client_secret: clientSecret,
}),
});
const body = await response.json().catch(() => ({}));
let body = await response.json().catch(() => ({}));
if (response.status !== 200 || body.error) {
return {error: body.error};
} else {
} else if (body.status === 'succeeded') {
return {paymentIntent: body};
} else if (body.status === 'requires_action') {
const success = await openModal(
'3D Secure\nDo you want to confirm or cancel?',
'Complete authentication', 'Fail authentication');
url = `${LOCALSTRIPE_BASE_API}/v1/payment_intents/${pi}` +
`/_authenticate?success=${success}`;
response = await fetch(url, {
method: 'POST',
body: JSON.stringify({
key: apiKey,
client_secret: clientSecret,
}),
});
body = await response.json().catch(() => ({}));
if (response.status !== 200 || body.error) {
return {error: body.error};
} else if (body.status === 'succeeded') {
return {paymentIntent: body};
} else { // 3D Secure authentication cancelled by user:
return {error: {message:
'The latest attempt to confirm the payment has failed ' +
'because authentication failed.'}};
}
} else {
return {error: {message: `payment_intent has status ${body.status}`}};
}
} catch (err) {
if (typeof err === 'object' && err.error) {
Expand Down
8 changes: 7 additions & 1 deletion localstripe/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1971,7 +1971,8 @@ def _api_create(cls, confirm=None, off_session=None, **data):
return obj

@classmethod
def _api_confirm(cls, id, payment_method=None, **kwargs):
def _api_confirm(cls, id, payment_method=None, client_secret=None,
**kwargs):
if kwargs:
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))

Expand All @@ -1980,11 +1981,16 @@ def _api_confirm(cls, id, payment_method=None, **kwargs):

try:
assert type(id) is str and id.startswith('pi_')
if client_secret is not None:
assert type(client_secret) is str
except AssertionError:
raise UserError(400, 'Bad request')

obj = cls._api_retrieve(id)

if client_secret and client_secret != obj.client_secret:
raise UserError(401, 'Unauthorized')

if obj.status != 'requires_confirmation':
raise UserError(400, 'Bad request')

Expand Down
1 change: 1 addition & 0 deletions localstripe/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ async def auth_middleware(request, handler):
r'^/v1/tokens$',
r'^/v1/sources$',
r'^/v1/payment_intents/\w+/_authenticate\b',
r'^/v1/payment_intents/\w+/confirm$',
r'^/v1/setup_intents/\w+/confirm$',
r'^/v1/setup_intents/\w+/cancel$',
)))
Expand Down
19 changes: 19 additions & 0 deletions samples/pm_setup/README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
localstripe sample: Set up a payment method for future payments
===============================================================

This is a demonstration of how to inject localstripe for testing a simplistic
Stripe web integration. This is derived from the Stripe instructions for
collecting payment methods on a single-page web app.

**This sample is not intended to represent best practice for production code!**

From the localstripe directory...

.. code:: shell
# Launch localstripe:
python -m localstripe --from-scratch &
# Launch this sample's server:
python -m samples.pm_setup.server
# ... now browse to http://0.0.0.0:8080 and try the test card
# 4242-4242-4242-4242.
26 changes: 26 additions & 0 deletions samples/pm_setup/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<head>
<title>
localstripe sample: Set up a payment method for future payments
</title>
<!-- this is a proxy script which chooses whether to load the real Stripe.js
or localstripe's mock: -->
<script src="stripe.js"></script>
<script type="module" src="pm_setup.js"></script>
</head>
<body>
<h1>
localstripe sample: Set up a payment method for future payments
</h1>
<form id="payment-method-form">
<p>Enter your (test) card information:</p>
<div id="payment-method-element"></div>
<button id="payment-method-submit">Submit</button>
<div id="payment-method-result-message"></div>
</form>
<form id="payment-form">
<label for="payment-amount">Make a payment in cents:</label>
<input id="payment-amount" name="amount" type="number"/>
<button id="payment-submit">Submit</button>
<div id="payment-result-message"></div>
</form>
</body>
82 changes: 82 additions & 0 deletions samples/pm_setup/pm_setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
let seti;
let setiClientSecret;
let stripe;
let paymentElement;

const init = async () => {
const response = await fetch('/setup_intent', {method: "POST"});
const {
id: id,
client_secret: clientSecret,
stripe_api_pk: publishableKey,
} = await response.json();

seti = id;
setiClientSecret = clientSecret;

stripe = Stripe(publishableKey);

const elements = stripe.elements({
clientSecret: clientSecret,
});

paymentElement = elements.create('card');

paymentElement.mount('#payment-method-element');

document.getElementById(
'payment-method-form',
).addEventListener('submit', handlePaymentMethodSubmit);

document.getElementById(
'payment-form',
).addEventListener('submit', handlePaymentSubmit);
}

let handlePaymentMethodSubmit = async (event) => {
event.preventDefault();

const {error} = await stripe.confirmCardSetup(setiClientSecret, {
payment_method: {
card: paymentElement,
},
});

const container = document.getElementById('payment-method-result-message');
if (error) {
container.textContent = error.message;
} else {
const response = await fetch('/payment_method', {
method: "POST",
body: JSON.stringify({ setup_intent: seti })
});
if (response.ok) {
container.textContent = "Successfully confirmed payment method!";
} else {
container.textContent = "Error confirming payment method!";
}
}
};

let handlePaymentSubmit = async (event) => {
event.preventDefault();

const response = await fetch('/payment_intent', {
method: "POST",
body: JSON.stringify({
amount: document.getElementById('payment-amount').value,
})
});
const {client_secret: clientSecret} = await response.json();

const {error} = await stripe.confirmCardPayment(clientSecret, {});

const container = document.getElementById('payment-result-message');
if (error) {
container.textContent = error.message;
} else {
container.textContent = "Successfully confirmed payment!";
}
};

await init();
156 changes: 156 additions & 0 deletions samples/pm_setup/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from argparse import ArgumentParser
from dataclasses import dataclass
from importlib.resources import as_file, files
import logging
from os import environ

from aiohttp import web
import stripe

use_real_stripe_api = False
stripe_api_pk = 'pk_test_12345'
stripe.api_key = 'sk_test_12345'


@dataclass
class CustomerState:
cus: str | None = None
pm: str | None = None


# Normally these values would be securely stored in a database, indexed by some
# authenticated customer identifier. For this sample, we have no authentication
# system so just store one global "customer":
customer_state = CustomerState()


app = web.Application()
routes = web.RouteTableDef()


@routes.get('/stripe.js')
async def stripe_js(request):
del request

global use_real_stripe_api

if use_real_stripe_api:
stripe_js_location = 'https://js.stripe.com/v3/'
else:
stripe_js_location = 'http://localhost:8420/js.stripe.com/v3/'

return web.Response(content_type='application/javascript', text=f"""\
const script = document.createElement('script');
script.src = "{stripe_js_location}";
document.head.appendChild(script);
""")


@routes.get('/pm_setup.js')
async def pm_setup_js(request):
del request

with as_file(files('samples.pm_setup').joinpath('pm_setup.js')) as f:
return web.FileResponse(f)


@routes.get('/')
async def index(request):
del request

global use_real_stripe_api

with files('samples.pm_setup').joinpath('index.html').open('r') as f:
return web.Response(
text=f.read(),
content_type='text/html',
)


@routes.post('/setup_intent')
async def setup_intent(request):
del request

global customer_state, stripe_api_pk

cus = stripe.Customer.create()
customer_state.cus = cus.id

seti = stripe.SetupIntent.create(
customer=cus.id,
payment_method_types=["card"],
)
return web.json_response(dict(
id=seti.id,
client_secret=seti.client_secret,
stripe_api_pk=stripe_api_pk,
))


@routes.post('/payment_method')
async def payment_method(request):
body = await request.json()

seti = stripe.SetupIntent.retrieve(body['setup_intent'])

customer_state.pm = seti.payment_method

return web.Response()


@routes.post('/payment_intent')
async def payment_intent(request):
global customer_state

body = await request.json()

pi = stripe.PaymentIntent.create(
customer=customer_state.cus,
payment_method=customer_state.pm,
amount=body['amount'],
currency='usd',
)

return web.json_response(dict(
client_secret=pi.client_secret,
))


app.add_routes(routes)


def main():
global stripe_api_pk, use_real_stripe_api

parser = ArgumentParser()
parser.add_argument(
'--real-stripe', action='store_true', help="""\
Use the actual Stripe API. This is useful for verifying this sample and
localstripe are providing an accurate simulation.
To use, you must set the environment variable SK to your Stripe account's
secret API key, and PK to your Stripe account's publishable API key. It is
obviously recommended that you use the test mode variant of your Stripe account
for this.
""")
args = parser.parse_args()

if args.real_stripe:
use_real_stripe_api = True
stripe.api_key = environ.get('SK')
stripe_api_pk = environ.get('PK')
if not stripe.api_key or not stripe_api_pk:
parser.print_help()
parser.exit(1)
else:
stripe.api_base = 'http://localhost:8420'

logger = logging.getLogger('aiohttp.access')
logger.setLevel(logging.DEBUG)
logger.addHandler(logging.StreamHandler())

web.run_app(app, access_log=logger)


if __name__ == '__main__':
main()
Loading

0 comments on commit 35b205d

Please sign in to comment.