[ADD] iap: In app purchase

IAP allow app publishers to charge for ongoing services. In that context, Odoo
acts mostly as a payment platform between the service user (client) and the service
provider (Odoo App developer).
This commit is contained in:
Antony Lesuisse
2017-10-04 20:22:22 +02:00
parent ea51b6b06f
commit 6b22476e20
23 changed files with 674 additions and 22 deletions
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+23
View File
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'In-App Purchases',
'category': 'Tools',
'summary': 'Basic models and helpers to support In-App purchases.',
'description': """
This module provides standard tools (account model, context manager and helpers) to support In-App purchases inside Odoo.
""",
'depends': ['web'],
'data': [
'security/ir.model.access.csv',
'security/ir_rule.xml',
'views/assets.xml',
'views/iap_views.xml',
],
'qweb': [
'static/src/xml/iap_templates.xml',
],
'auto_install': True,
}
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import iap
+157
View File
@@ -0,0 +1,157 @@
# -*- coding: utf-8 -*-
import contextlib
import logging
import json
import uuid
import werkzeug.urls
import requests
from requests.packages import urllib3
from odoo import api, fields, models, exceptions
_logger = logging.getLogger(__name__)
DEFAULT_ENDPOINT = 'https://iap.odoo.com'
#----------------------------------------------------------
# Helpers for both clients and proxy
#----------------------------------------------------------
def get_endpoint(env):
url = env['ir.config_parameter'].sudo().get_param('iap.endpoint', DEFAULT_ENDPOINT)
return url
#----------------------------------------------------------
# Helpers for clients
#----------------------------------------------------------
class InsufficientCreditError(Exception):
pass
class AuthenticationError(Exception):
pass
def jsonrpc(url, method='call', params=None):
"""
Calls the provided JSON-RPC endpoint, unwraps the result and
returns JSON-RPC errors as exceptions.
"""
payload = {
'jsonrpc': '2.0',
'method': method,
'params': params,
'id': uuid.uuid4().hex,
}
_logger.info('iap jsonrpc %s', url)
try:
req = requests.post(url, json=payload)
response = req.json()
if 'error' in response:
name = response['error']['data'].get('name').rpartition('.')[-1]
message = response['error']['data'].get('message')
if name == 'InsufficientCreditError':
e_class = InsufficientCreditError
elif name == 'AccessError':
e_class = exceptions.AccessError
else:
e_class = exceptions.UserError
e = e_class(message)
e.data = response['error']['data']
raise e
return response.get('result')
except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, urllib3.exceptions.MaxRetryError) as e:
raise exceptions.AccessError('The url that this service requested returned an error. Please contact the author the app. The url it tried to contact was ' + url)
#----------------------------------------------------------
# Helpers for proxy
#----------------------------------------------------------
@contextlib.contextmanager
def charge(env, key, account_token, credit, description=None, credit_template=None):
"""
Account charge context manager: takes a hold for ``credit``
amount before executing the body, then captures it if there
is no error, or cancels it if the body generates an exception.
:param str key: service identifier
:param str account_token: user identifier
:param int credit: cost of the body's operation
:param str description:
"""
end_point = get_endpoint(env)
params = {
'account_token': account_token,
'credit': credit,
'key': key,
'description': description,
}
try:
transaction_token = jsonrpc(endpoint + '/iap/1/authorize', params=params)
except InsufficientCreditError as e:
if credit_template:
arguments = json.loads(e.args[0])
arguments['body'] = env['ir.qweb'].render(credit_template)
e.args = (json.dumps(arguments),)
try:
yield
except Exception as e:
params = {
'token': transaction_token,
'key': key,
}
r = jsonrpc(end_point + '/iap/1/cancel', params=params)
raise e
else:
params = {
'token': transaction_token,
'key': key,
}
r = jsonrpc(end_point + '/iap/1/capture', params=params) # noqa
#----------------------------------------------------------
# Models for client
#----------------------------------------------------------
class IapAccount(models.Model):
_name = 'iap.account'
_rec_name = 'service_name'
service_name = fields.Char()
account_token = fields.Char(default=lambda s: uuid.uuid4().hex)
company_id = fields.Many2one('res.company', default=lambda self: self.env.user.company_id)
@api.model
def get(self, service_name):
account = self.search([('service_name', '=', service_name), ('company_id', 'in', [self.env.user.company_id.id, False])])
if not account:
account = self.create({'service_name': service_name})
# Since the account did not exist yet, we will encounter a NoCreditError,
# which is going to rollback the database and undo the account creation,
# preventing the process to continue any further.
self.env.cr.commit()
return account
@api.model
def get_credits_url(self, base_url, service_name, credit):
dbuuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
account_token = self.get(service_name).account_token
d = {
'dbuuid': dbuuid,
'service_name': service_name,
'account_token': account_token,
'credit': credit,
}
return '%s?%s' % (base_url, werkzeug.urls.url_encode(d))
@api.model
def get_account_url(self):
route = '/iap/services'
endpoint = get_endpoint(self.env)
d = {'dbuuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid')}
return '%s?%s' % (endpoint + route, werkzeug.urls.url_encode(d))
+3
View File
@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_client_iap_account_manager,iap.account.manager,model_iap_account,base.group_system,1,1,1,0
access_client_iap_account_user,iap.account.user,model_iap_account,base.group_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_client_iap_account_manager iap.account.manager model_iap_account base.group_system 1 1 1 0
3 access_client_iap_account_user iap.account.user model_iap_account base.group_user 1 0 0 0
+13
View File
@@ -0,0 +1,13 @@
<odoo>
<data>
<record id="user_iap_account" model="ir.rule">
<field name="name">User IAP Account</field>
<field name="model_id" ref="model_iap_account"/>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
<!-- partners can CUD services linked to themselves -->
<field name="domain_force">[
('company_id', 'in', [user.company_id.id, False]),
]</field>
</record>
</data>
</odoo>
+49
View File
@@ -0,0 +1,49 @@
odoo.define('iap.CrashManager', function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var CrashManager = require('web.CrashManager');
var Dialog = require('web.Dialog');
var _t = core._t;
var QWeb = core.qweb;
CrashManager.include({
/**
* @override
*/
rpc_error: function (error) {
if (error.data.name === "odoo.addons.iap.models.iap.InsufficientCreditError") {
var error_data = JSON.parse(error.data.message);
ajax.jsonRpc('/web/dataset/call_kw', 'call', {
model: 'iap.account',
method: 'get_credits_url',
args: [],
kwargs: {
base_url: error_data.base_url,
service_name: error_data.service_name,
credit: error_data.credit,
}
}).then(function (url) {
new Dialog(this, {
size: 'large',
title: error_data.title || _t("Insufficient Balance"),
$content: $(QWeb.render('iap.redirect_to_odoo_credit', {
data: error_data,
})).css('padding', 0),
buttons: [
{text: 'Buy credits at Odoo', classes : "btn-primary", click: function() {
window.open(url, '_blank');
}, close:true},
{text: _t("Cancel"), close: true}
],
}).open();
});
} else {
this._super.apply(this, arguments);
}
},
});
});
+29
View File
@@ -0,0 +1,29 @@
odoo.define('iap.Dashboard', function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var Dashboard = require('web_settings_dashboard');
var _t = core._t;
var QWeb = core.qweb;
Dashboard.Dashboard.include({
/**
* @override
*/
load_apps: function (data) {
var _super = this._super.bind(this);
return ajax.jsonRpc('/web/dataset/call_kw', 'call', {
model: 'iap.account',
method: 'get_account_url',
args: [],
kwargs: {},
}).then(function (url) {
data.apps.url = url;
return _super(data);
});
},
});
});
+28
View File
@@ -0,0 +1,28 @@
odoo.define('iap.redirect_odoo_credit_widget', function(require) {
"use strict";
var core = require('web.core');
var framework = require('web.framework');
var Widget = require('web.Widget');
var QWeb = core.qweb;
var IapOdooCreditRedirect = Widget.extend({
template: 'iap.redirect_to_odoo_credit',
events : {
"click .redirect_confirm" : "odoo_redirect",
},
init: function (parent, action) {
this._super(parent, action);
this.url = action.params.url;
},
odoo_redirect: function () {
window.open(this.url, '_blank');
this.do_action({type: 'ir.actions.act_window_close'});
// framework.redirect(this.url);
},
});
core.action_registry.add('iap_odoo_credit_redirect', IapOdooCreditRedirect);
});
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<template id="template" xml:space="preserve">
<!-- LAYOUT TEMPLATES -->
<div t-name="iap.redirect_to_odoo_credit">
<t t-if="data.body">
<div t-raw="data.body"/>
</t>
<t t-if="!data.body">
<t t-if="data.message">
<span t-esc="data.message"/>
</t>
<t t-if="!data.message">
<span>Insufficient credit to perform this service.</span>
</t>
</t>
</div>
<t t-extend="DashboardApps">
<t t-jquery=".o_web_settings_dashboard_pills" t-operation="append">
<a class="pull-right" t-att-href="widget.data.url" target="_blank">
<i class="fa fa-money fa-2x text-muted"/> In-App Purchases</a>
</t>
</t>
</template>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" name="iap assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/iap/static/src/js/iap_credit.js"></script>
<script type="text/javascript" src="/iap/static/src/js/crash_manager.js"></script>
<script type="text/javascript" src="/iap/static/src/js/dashboard.js"></script>
</xpath>
</template>
</odoo>
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- iap Client Account Views -->
<record id="iap_account_view_form" model="ir.ui.view">
<field name="name">iap.account.form</field>
<field name="model">iap.account</field>
<field name="arch" type="xml">
<form string="IAP Account">
<sheet>
<group name="account" string="Account Information">
<field name="service_name"/>
<field name="company_id"/>
<field name="account_token"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="iap_account_view_tree" model="ir.ui.view">
<field name="name">iap.account.tree</field>
<field name="model">iap.account</field>
<field name="arch" type="xml">
<tree string="IAP Accounts">
<field name="service_name"/>
<field name="company_id"/>
<field name="account_token" readonly="1"/>
</tree>
</field>
</record>
<!-- Actions -->
<record id="iap_account_action" model="ir.actions.act_window">
<field name="name">IAP Account</field>
<field name="res_model">iap.account</field>
<field name='view_type'>form</field>
<field name='view_mode'>tree,form</field>
</record>
<!-- Menus -->
<menuitem
id="iap_root_menu"
name="IAP"
parent="base.menu_custom"
sequence="4"/>
<menuitem
id="iap_account_menu"
name="IAP Accounts"
parent="iap_root_menu"
action="iap_account_action"
sequence="10"/>
</odoo>
@@ -345,6 +345,7 @@ core.action_registry.add('web_settings_dashboard.main', Dashboard);
return {
Dashboard: Dashboard,
DashboardApps: DashboardApps,
DashboardInvitations: DashboardInvitations,
DashboardPlanner: DashboardPlanner,
DashboardShare: DashboardShare,
+2 -2
View File
@@ -103,7 +103,7 @@ main.has_code_col{
> *{
max-width: 100%;
}
section {
> section {
position: relative;
display:block;
float: left;
@@ -116,7 +116,7 @@ main.has_code_col{
&:before {
.code-col();
}
section {
> section {
> * {
width: 54.633333%;
max-width: 600px;
+12 -12
View File
@@ -9708,7 +9708,7 @@ main.has_code_col article.doc-body {
main.has_code_col article.doc-body > * {
max-width: 100%;
}
main.has_code_col article.doc-body section {
main.has_code_col article.doc-body > section {
position: relative;
display: block;
float: left;
@@ -9730,36 +9730,36 @@ main.has_code_col article.doc-body section {
top: 0;
right: 0;
}
main.has_code_col article.doc-body section > * {
main.has_code_col article.doc-body > section > * {
width: 54.633333%;
max-width: 600px;
float: left;
clear: left;
}
main.has_code_col article.doc-body section > h1,
main.has_code_col article.doc-body section > h2,
main.has_code_col article.doc-body section > h3,
main.has_code_col article.doc-body section > h4,
main.has_code_col article.doc-body section > h5,
main.has_code_col article.doc-body section > h6 {
main.has_code_col article.doc-body > section > h1,
main.has_code_col article.doc-body > section > h2,
main.has_code_col article.doc-body > section > h3,
main.has_code_col article.doc-body > section > h4,
main.has_code_col article.doc-body > section > h5,
main.has_code_col article.doc-body > section > h6 {
width: 100%;
float: none;
clear: none;
}
main.has_code_col article.doc-body section .doc-aside {
main.has_code_col article.doc-body > section .doc-aside {
width: 41%;
float: none;
clear: none;
margin-right: 15px;
margin-left: 57%;
}
main.has_code_col article.doc-body section .doc-aside .content-switcher {
main.has_code_col article.doc-body > section .doc-aside .content-switcher {
margin-top: 0;
}
main.has_code_col article.doc-body section .doc-aside .content-switcher > ul {
main.has_code_col article.doc-body > section .doc-aside .content-switcher > ul {
margin-bottom: 0;
}
main.has_code_col article.doc-body section .doc-aside .content-switcher > ul > li {
main.has_code_col article.doc-body > section .doc-aside .content-switcher > ul > li {
color: #dcddde;
}
}
+5
View File
@@ -142,6 +142,11 @@ class BootstrapTranslator(nodes.NodeVisitor, object):
if not self.section_level:
self.body.append(u'</section>')
def visit_topic(self, node):
self.body.append(self.starttag(node, 'nav'))
def depart_topic(self, node):
self.body.append(u'</nav>')
def is_compact_paragraph(self, node):
parent = node.parent
if isinstance(parent, (nodes.document, nodes.compound,
+1 -1
View File
@@ -14,7 +14,7 @@ Index
:maxdepth: 2
tutorials
api_integration
webservices
setup
reference
-1
View File
@@ -21,5 +21,4 @@ Reference
reference/reports
reference/mixins
reference/guidelines
reference/upgrade_api
reference/mobile
+13
View File
@@ -0,0 +1,13 @@
:banner: banners/web_service_api.jpg
:types: api
============
Web Services
============
.. toctree::
:titlesonly:
webservices/odoo
webservices/iap
webservices/upgrade
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+235
View File
@@ -0,0 +1,235 @@
:types: api
:code-column:
.. _webservices/iap:
================
In-App Purchases
================
IAP allow providers of ongoing services through Odoo apps to be compensated
for ongoing service use rather than — and possibly instead of — a sole initial
purchase.
In that context, Odoo acts mostly as a *broker* between the service user
(client) and the service provider (Odoo App developer):
* users purchase service tokens from Odoo
* service providers draw tokens from the user's Odoo account
.. attention::
This document is intended for *service providers* and presents the latter,
which can be done either via direct JSON-RPC2_ or if you are using Odoo
using the convenience helpers it provides.
.. image:: flow.png
:align: center
.. contents::
:local:
JSON-RPC2_ Transaction API
==========================
* The IAP transaction API does not require using Odoo when implementing your
server gateway, calls are standard JSON-RPC2_.
* Calls use different *endpoints* but the same *method* on all endpoints
(``call``).
* Exceptions are returned as JSON-RPC2_ errors, the formal exception name is
available on ``data.name`` for programmatic manipulation.
.. class:: ServiceKey
Identifier generated for the provider's service. Each key (and service)
matches a token of a fixed value, as generated by the service provide.
Multiple types of tokens correspond to multiple services e.g. SMS and MMS
could either be the same service (with an MMS being "worth" multiple SMS)
or could be separate services at separate price points.
.. danger:: your service key *is a secret*, leaking your service key
allows other application developers to draw credits bought for
your service(s)
.. class:: UserToken
Identifier for a user account.
.. class:: TransactionToken
Transaction identifier, returned by the authorization process and consumed
by either capturing or cancelling the transaction
.. exception:: odoo.addons.iap.models.iap.NoCreditError
Raised during transaction authorization if the credits requested are not
currently available on the account (either not enough credits or too many
pending transactions/existing holds).
.. exception:: odoo.addons.iap.models.iap.BadAuthError
Raised by any operation to which a service token is required, if the
service token is invalid.
Authorize
---------
.. function:: /iap/1/authorize
Verifies that the user's account has at least as ``credit`` available
*and creates a hold (pending transaction) on that amount*.
Any amount currently on hold by a pending transaction is considered
unavailable to further authorize calls.
Returns a :class:`TransactionToken` identifying the pending transaction
which can be used to capture (confirm) or cancel said transaction.
:param ServiceKey key:
:param UserToken account_token:
:param int credit:
:param str description: optional, helps users identify the reason for
charges on their accounts.
:returns: :class:`TransactionToken` if the authorization succeeded.
:raises: :class:`~odoo.addons.iap.models.iap.BadAuthError` if the service token is invalid
:raises: :class:`~odoo.addons.iap.models.iap.NoCreditError` if the account does
:raises: ``TypeError`` if the ``credit`` value is not an integer
.. rst-class:: doc-aside
.. code-block:: python
r = requests.post(ODOO + '/iap/1/authorize', json={
'jsonrpc': '2.0',
'id': None,
'method': 'call',
'params': {
'account_token': user_account,
'key': SERVICE_KEY,
'credit': 25,
'description': "Why this is being charged",
}
}).json()
if 'error' in r:
# handle authorize error
tx = r['result']
# provide your service here
Capture
-------
.. function:: /iap/1/capture
Confirms the specified transaction, transferring the reserved credits from
the user's account to the service provider's.
Capture calls are idempotent: performing capture calls on an already
captured transaction has no further effect.
:param TransactionToken token:
:param ServiceKey key:
:raises: :class:`~odoo.addons.iap.models.iap.BadAuthError`
.. rst-class:: doc-aside
.. code-block:: python
r2 = requests.post(ODOO + '/iap/1/capture', json={
'jsonrpc': '2.0',
'id': None,
'method': 'call',
'params': {
'token': tx,
'key': SERVICE_KEY,
}
}).json()
if 'error' in r:
# handle capture error
# otherwise transaction is captured
Cancel
------
.. function:: /iap/1/cancel
Cancels the specified transaction, releasing the hold on the user's
credits.
Cancel calls are idempotent: performing capture calls on an already
cancelled transaction has no further effect.
:param TransactionToken token:
:param ServiceKey key:
:raises: :class:`~odoo.addons.iap.models.iap.BadAuthError`
.. rst-class:: doc-aside
.. code-block:: python
r2 = requests.post(ODOO + '/iap/1/cancel', json={
'jsonrpc': '2.0',
'id': None,
'method': 'call',
'params': {
'token': tx,
'key': SERVICE_KEY,
}
}).json()
if 'error' in r:
# handle cancel error
# otherwise transaction is cancelled
Odoo Helpers
============
For convenience, if you are implementing your service using Odoo the ``iap``
module provides a few helpers to make IAP flow even simpler:
Charging
--------
.. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description])
A *context manager* for authorizing and automatically capturing or
cancelling transactions for use in the backend/proxy.
Works much like e.g. a cursor context manager:
* immediately authorizes a transaction with the specified parameters
* executes the ``with`` body
* if the body executes in full without error, captures the transaction
* otherwise cancels it
:param odoo.api.Environment env: used to retrieve the ``iap.endpoint``
configuration key
:param ServiceKey key:
:param UserToken token:
:param int credit:
:param str description:
.. rst-class:: doc-aside
.. code-block:: python
@route('/deathstar/superlaser', type='json')
def superlaser(self, user_account,
coordinates, target,
factor=1.0):
"""
:param factor: superlaser power factor,
0.0 is none, 1.0 is full power
"""
credits = int(MAXIMUM_POWER * factor)
with charge(request.env, SERVICE_KEY, user_account, credits):
# TODO: allow other targets
self.env['systems.planets'].search([
('grid', '=', 'M-10'),
('name', '=', 'Alderaan'),
]).unlink()
.. _JSON-RPC2: http://www.jsonrpc.org/specification
@@ -4,9 +4,9 @@
:code-column:
===============
Web Service API
===============
===========================
Odoo Remote Procedure Calls
===========================
Odoo is usually extended internally via modules, but many of its features and
all of its data are also available from the outside for external analysis or
@@ -6,9 +6,9 @@
.. _reference/upgrade-api:
===========
Upgrade API
===========
================
Database Upgrade
================
Introduction
~~~~~~~~~~~~