Skip to content

Commit 0f8930a

Browse files
author
Ben Creech
committed
Fix confirmCardPayment in localstripe-v3.js and add an example
I wanted to add an example of using localstripe-v3.js, as a way of testing #240 and also because it seems generally useful as documentation. The example includes a --real-stripe argument so you can compare the real (test) Stripe API and Stripe.js against localstripe. 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).
1 parent 6b0ff20 commit 0f8930a

File tree

9 files changed

+342
-10
lines changed

9 files changed

+342
-10
lines changed

README.rst

+3
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ JavaScript source in the web page before it creates card elements:
193193
194194
<script src="http://localhost:8420/js.stripe.com/v3/"></script>
195195
196+
See the ``samples/pm_setup`` directory for an example of this with a
197+
very minimalistic Stripe elements application.
198+
196199
Use webhooks
197200
------------
198201

localstripe/localstripe-v3.js

+30-9
Original file line numberDiff line numberDiff line change
@@ -340,24 +340,45 @@ Stripe = (apiKey) => {
340340
confirmCardPayment: async (clientSecret, data) => {
341341
console.log('localstripe: Stripe().confirmCardPayment()');
342342
try {
343-
const success = await openModal(
344-
'3D Secure\nDo you want to confirm or cancel?',
345-
'Complete authentication', 'Fail authentication');
346343
const pi = clientSecret.match(/^(pi_\w+)_secret_/)[1];
347-
const url = `${LOCALSTRIPE_BASE_API}/v1/payment_intents/${pi}` +
348-
`/_authenticate?success=${success}`;
349-
const response = await fetch(url, {
344+
let url = `${LOCALSTRIPE_BASE_API}/v1/payment_intents/${pi}/confirm`;
345+
let response = await fetch(url, {
350346
method: 'POST',
351-
body: JSON.stringify({
347+
body: new URLSearchParams({
352348
key: apiKey,
353349
client_secret: clientSecret,
354350
}),
355351
});
356-
const body = await response.json().catch(() => ({}));
352+
let body = await response.json().catch(() => ({}));
357353
if (response.status !== 200 || body.error) {
358354
return {error: body.error};
359-
} else {
355+
} else if (body.status === 'succeeded') {
360356
return {paymentIntent: body};
357+
} else if (body.status === 'requires_action') {
358+
const success = await openModal(
359+
'3D Secure\nDo you want to confirm or cancel?',
360+
'Complete authentication', 'Fail authentication');
361+
url = `${LOCALSTRIPE_BASE_API}/v1/payment_intents/${pi}` +
362+
`/_authenticate?success=${success}`;
363+
response = await fetch(url, {
364+
method: 'POST',
365+
body: JSON.stringify({
366+
key: apiKey,
367+
client_secret: clientSecret,
368+
}),
369+
});
370+
body = await response.json().catch(() => ({}));
371+
if (response.status !== 200 || body.error) {
372+
return {error: body.error};
373+
} else if (body.status === 'succeeded') {
374+
return {paymentIntent: body};
375+
} else { // 3D Secure authentication cancelled by user:
376+
return {error: {message:
377+
'The latest attempt to confirm the payment has failed ' +
378+
'because authentication failed.'}};
379+
}
380+
} else {
381+
return {error: {message: `payment_intent has status ${body.status}`}};
361382
}
362383
} catch (err) {
363384
if (typeof err === 'object' && err.error) {

localstripe/resources.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1971,7 +1971,8 @@ def _api_create(cls, confirm=None, off_session=None, **data):
19711971
return obj
19721972

19731973
@classmethod
1974-
def _api_confirm(cls, id, payment_method=None, **kwargs):
1974+
def _api_confirm(cls, id, payment_method=None, client_secret=None,
1975+
**kwargs):
19751976
if kwargs:
19761977
raise UserError(400, 'Unexpected ' + ', '.join(kwargs.keys()))
19771978

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

19811982
try:
19821983
assert type(id) is str and id.startswith('pi_')
1984+
if client_secret is not None:
1985+
assert type(client_secret) is str
19831986
except AssertionError:
19841987
raise UserError(400, 'Bad request')
19851988

19861989
obj = cls._api_retrieve(id)
19871990

1991+
if client_secret and client_secret != obj.client_secret:
1992+
raise UserError(401, 'Unauthorized')
1993+
19881994
if obj.status != 'requires_confirmation':
19891995
raise UserError(400, 'Bad request')
19901996

localstripe/server.py

+1
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ async def auth_middleware(request, handler):
168168
r'^/v1/tokens$',
169169
r'^/v1/sources$',
170170
r'^/v1/payment_intents/\w+/_authenticate\b',
171+
r'^/v1/payment_intents/\w+/confirm$',
171172
r'^/v1/setup_intents/\w+/confirm$',
172173
r'^/v1/setup_intents/\w+/cancel$',
173174
)))

samples/pm_setup/README.rst

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
localstripe sample: Set up a payment method for future payments
2+
===============================================================
3+
4+
This is a demonstration of how to inject localstripe for testing a simplistic
5+
Stripe web integration. This is derived from the Stripe instructions for
6+
collecting payment methods on a single-page web app.
7+
8+
**This sample is not intended to represent best practice for production code!**
9+
10+
From the localstripe directory...
11+
12+
.. code:: shell
13+
14+
# Launch localstripe:
15+
python -m localstripe --from-scratch &
16+
# Launch this sample's server:
17+
python -m samples.pm_setup.server
18+
# ... now browse to http://0.0.0.0:8080 and try the test card
19+
# 4242-4242-4242-4242.

samples/pm_setup/index.html

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<head>
2+
<title>
3+
localstripe sample: Set up a payment method for future payments
4+
</title>
5+
<!-- this is a proxy script which chooses whether to load the real Stripe.js
6+
or localstripe's mock: -->
7+
<script src="stripe.js"></script>
8+
<script type="module" src="pm_setup.js"></script>
9+
</head>
10+
<body>
11+
<h1>
12+
localstripe sample: Set up a payment method for future payments
13+
</h1>
14+
<form id="payment-method-form">
15+
<p>Enter your (test) card information:</p>
16+
<div id="payment-method-element"></div>
17+
<button id="payment-method-submit">Submit</button>
18+
<div id="payment-method-result-message"></div>
19+
</form>
20+
<form id="payment-form">
21+
<label for="payment-amount">Make a payment in cents:</label>
22+
<input id="payment-amount" name="amount" type="number"/>
23+
<button id="payment-submit">Submit</button>
24+
<div id="payment-result-message"></div>
25+
</form>
26+
</body>

samples/pm_setup/pm_setup.js

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
let seti;
2+
let setiClientSecret;
3+
let stripe;
4+
let paymentElement;
5+
6+
const init = async () => {
7+
const response = await fetch('/setup_intent', {method: "POST"});
8+
const {
9+
id: id,
10+
client_secret: clientSecret,
11+
stripe_api_pk: publishableKey,
12+
} = await response.json();
13+
14+
seti = id;
15+
setiClientSecret = clientSecret;
16+
17+
stripe = Stripe(publishableKey);
18+
19+
const elements = stripe.elements({
20+
clientSecret: clientSecret,
21+
});
22+
23+
paymentElement = elements.create('card');
24+
25+
paymentElement.mount('#payment-method-element');
26+
27+
document.getElementById(
28+
'payment-method-form',
29+
).addEventListener('submit', handlePaymentMethodSubmit);
30+
31+
document.getElementById(
32+
'payment-form',
33+
).addEventListener('submit', handlePaymentSubmit);
34+
}
35+
36+
let handlePaymentMethodSubmit = async (event) => {
37+
event.preventDefault();
38+
39+
const {error} = await stripe.confirmCardSetup(setiClientSecret, {
40+
payment_method: {
41+
card: paymentElement,
42+
},
43+
});
44+
45+
const container = document.getElementById('payment-method-result-message');
46+
if (error) {
47+
container.textContent = error.message;
48+
} else {
49+
const response = await fetch('/payment_method', {
50+
method: "POST",
51+
body: JSON.stringify({ setup_intent: seti })
52+
});
53+
if (response.ok) {
54+
container.textContent = "Successfully confirmed payment method!";
55+
} else {
56+
container.textContent = "Error confirming payment method!";
57+
}
58+
}
59+
};
60+
61+
let handlePaymentSubmit = async (event) => {
62+
event.preventDefault();
63+
64+
const response = await fetch('/payment_intent', {
65+
method: "POST",
66+
body: JSON.stringify({
67+
amount: document.getElementById('payment-amount').value,
68+
})
69+
});
70+
const {client_secret: clientSecret} = await response.json();
71+
72+
const {error} = await stripe.confirmCardPayment(clientSecret, {});
73+
74+
const container = document.getElementById('payment-result-message');
75+
if (error) {
76+
container.textContent = error.message;
77+
} else {
78+
container.textContent = "Successfully confirmed payment!";
79+
}
80+
};
81+
82+
await init();

samples/pm_setup/server.py

+156
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from argparse import ArgumentParser
2+
from dataclasses import dataclass
3+
from importlib.resources import as_file, files
4+
import logging
5+
from os import environ
6+
7+
from aiohttp import web
8+
import stripe
9+
10+
use_real_stripe_api = False
11+
stripe_api_pk = 'pk_test_12345'
12+
stripe.api_key = 'sk_test_12345'
13+
14+
15+
@dataclass
16+
class CustomerState:
17+
cus: str | None = None
18+
pm: str | None = None
19+
20+
21+
# Normally these values would be securely stored in a database, indexed by some
22+
# authenticated customer identifier. For this sample, we have no authentication
23+
# system so just store one global "customer":
24+
customer_state = CustomerState()
25+
26+
27+
app = web.Application()
28+
routes = web.RouteTableDef()
29+
30+
31+
@routes.get('/stripe.js')
32+
async def stripe_js(request):
33+
del request
34+
35+
global use_real_stripe_api
36+
37+
if use_real_stripe_api:
38+
stripe_js_location = 'https://js.stripe.com/v3/'
39+
else:
40+
stripe_js_location = 'http://localhost:8420/js.stripe.com/v3/'
41+
42+
return web.Response(content_type='application/javascript', text=f"""\
43+
const script = document.createElement('script');
44+
script.src = "{stripe_js_location}";
45+
document.head.appendChild(script);
46+
""")
47+
48+
49+
@routes.get('/pm_setup.js')
50+
async def pm_setup_js(request):
51+
del request
52+
53+
with as_file(files('samples.pm_setup').joinpath('pm_setup.js')) as f:
54+
return web.FileResponse(f)
55+
56+
57+
@routes.get('/')
58+
async def index(request):
59+
del request
60+
61+
global use_real_stripe_api
62+
63+
with files('samples.pm_setup').joinpath('index.html').open('r') as f:
64+
return web.Response(
65+
text=f.read(),
66+
content_type='text/html',
67+
)
68+
69+
70+
@routes.post('/setup_intent')
71+
async def setup_intent(request):
72+
del request
73+
74+
global customer_state, stripe_api_pk
75+
76+
cus = stripe.Customer.create()
77+
customer_state.cus = cus.id
78+
79+
seti = stripe.SetupIntent.create(
80+
customer=cus.id,
81+
payment_method_types=["card"],
82+
)
83+
return web.json_response(dict(
84+
id=seti.id,
85+
client_secret=seti.client_secret,
86+
stripe_api_pk=stripe_api_pk,
87+
))
88+
89+
90+
@routes.post('/payment_method')
91+
async def payment_method(request):
92+
body = await request.json()
93+
94+
seti = stripe.SetupIntent.retrieve(body['setup_intent'])
95+
96+
customer_state.pm = seti.payment_method
97+
98+
return web.Response()
99+
100+
101+
@routes.post('/payment_intent')
102+
async def payment_intent(request):
103+
global customer_state
104+
105+
body = await request.json()
106+
107+
pi = stripe.PaymentIntent.create(
108+
customer=customer_state.cus,
109+
payment_method=customer_state.pm,
110+
amount=body['amount'],
111+
currency='usd',
112+
)
113+
114+
return web.json_response(dict(
115+
client_secret=pi.client_secret,
116+
))
117+
118+
119+
app.add_routes(routes)
120+
121+
122+
def main():
123+
global stripe_api_pk, use_real_stripe_api
124+
125+
parser = ArgumentParser()
126+
parser.add_argument(
127+
'--real-stripe', action='store_true', help="""\
128+
Use the actual Stripe API. This is useful for verifying this sample and
129+
localstripe are providing an accurate simulation.
130+
131+
To use, you must set the environment variable SK to your Stripe account's
132+
secret API key, and PK to your Stripe account's publishable API key. It is
133+
obviously recommended that you use the test mode variant of your Stripe account
134+
for this.
135+
""")
136+
args = parser.parse_args()
137+
138+
if args.real_stripe:
139+
use_real_stripe_api = True
140+
stripe.api_key = environ.get('SK')
141+
stripe_api_pk = environ.get('PK')
142+
if not stripe.api_key or not stripe_api_pk:
143+
parser.print_help()
144+
parser.exit(1)
145+
else:
146+
stripe.api_base = 'http://localhost:8420'
147+
148+
logger = logging.getLogger('aiohttp.access')
149+
logger.setLevel(logging.DEBUG)
150+
logger.addHandler(logging.StreamHandler())
151+
152+
web.run_app(app, access_log=logger)
153+
154+
155+
if __name__ == '__main__':
156+
main()

0 commit comments

Comments
 (0)