diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 80520fab812..156169896a4 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -248,7 +248,7 @@ class AccountInvoice(models.Model):
move_name = fields.Char(string='Journal Entry Name', readonly=False,
default=False, copy=False,
help="Technical field holding the number given to the invoice, automatically set when the invoice is validated then stored to set the same number again if the invoice is cancelled, set to draft and re-validated.")
- reference = fields.Char(string='Vendor Reference',
+ reference = fields.Char(string='Vendor Reference', copy=False,
help="The partner reference of this invoice.", readonly=True, states={'draft': [('readonly', False)]})
reference_type = fields.Selection('_get_reference_type', string='Payment Reference',
required=True, readonly=True, states={'draft': [('readonly', False)]},
@@ -1582,9 +1582,6 @@ class AccountInvoiceLine(models.Model):
self.price_unit = 0.0
domain['uom_id'] = []
else:
- # Use the purchase uom by default
- self.uom_id = self.product_id.uom_po_id
-
if part.lang:
product = self.product_id.with_context(lang=part.lang)
else:
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js
index dda6ffa0664..d292c83a8df 100644
--- a/addons/account/static/tests/reconciliation_tests.js
+++ b/addons/account/static/tests/reconciliation_tests.js
@@ -658,6 +658,98 @@ QUnit.module('account', {
clientAction.destroy();
});
+ QUnit.test('Reconciliation validate without proposition', function (assert) {
+ assert.expect(1);
+ // Test added to prevent this issue happening again: https://github.com/odoo/odoo/commit/3549688b21eb65e16b9c3f2b6462eb8d8b52cd47
+ var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options);
+ testUtils.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$"
+ }
+ }
+ },
+ });
+
+ clientAction.appendTo($('#qunit-fixture'));
+
+ var widget = clientAction.widgets[0];
+ // Ensure that when we validate a line without any selection, it is the same
+ // as when we manually create a line with the line.balance and that only one
+ // line is send back to server.
+ testUtils.intercept(clientAction, 'call_service', function (event) {
+ assert.deepEqual(event.data.args[1].args,
+ [[5],[{partner_id: 8, counterpart_aml_dicts: [],
+ payment_aml_ids: [], new_aml_dicts: [{
+ account_id: 287,
+ credit: 1175,
+ debit: 0,
+ name: 'SAJ/2014/002 and SAJ/2014/003'
+ }]}]],
+ "Should call process_reconciliations with ids");
+ });
+
+ // click on validate button
+ widget.$('button.o_validate:not(:hidden)').trigger('click');
+ clientAction.destroy();
+ });
+
+ QUnit.test('Reconciliation validate with proposition', function (assert) {
+ assert.expect(1);
+ // Test added to check this functionality: https://github.com/odoo/odoo/commit/2f3b469dee6f18cbccce1cdf2a81cfe57960c533
+ var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options);
+ testUtils.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$"
+ }
+ }
+ },
+ });
+
+ clientAction.appendTo($('#qunit-fixture'));
+
+ var widget = clientAction.widgets[0];
+ // Add a line as proposition
+ // open the first line
+ widget.$('.accounting_view thead td:first').trigger('click');
+ // select propositions
+ widget.$('.match .cell_account_code:first').trigger('click');
+
+ // Ensure that when we validate a line with propositions and that there is a remaining balance
+ // We also create a line which is the open balance.
+ testUtils.intercept(clientAction, 'call_service', function (event) {
+ assert.deepEqual(event.data.args[1].args,
+ [[5],[{partner_id: 8,
+ counterpart_aml_dicts: [{
+ counterpart_aml_id: 109,
+ credit: 650,
+ debit: 0,
+ name: 'INV/2017/0002'
+ }],
+ payment_aml_ids: [],
+ new_aml_dicts: [{
+ account_id: 287,
+ credit: 525,
+ debit: 0,
+ name: 'SAJ/2014/002 and SAJ/2014/003 : Open balance'
+ }]}]],
+ "Should call process_reconciliations with ids");
+ });
+
+ // click on validate button
+ widget.$('button.o_validate:not(:hidden)').trigger('click');
+ clientAction.destroy();
+ });
+
QUnit.test('Reconciliation partial', function (assert) {
assert.expect(10);
diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py
index ac2aca16951..b91d4748954 100644
--- a/addons/account/tests/test_reconciliation.py
+++ b/addons/account/tests/test_reconciliation.py
@@ -20,13 +20,14 @@ class TestReconciliation(AccountingTestCase):
self.res_currency_model = self.registry('res.currency')
self.res_currency_rate_model = self.registry('res.currency.rate')
- self.partner_agrolait_id = self.env.ref("base.res_partner_2").id
+ partner_agrolait = self.env.ref("base.res_partner_2")
+ self.partner_agrolait_id = partner_agrolait.id
self.currency_swiss_id = self.env.ref("base.CHF").id
self.currency_usd_id = self.env.ref("base.USD").id
self.currency_euro_id = self.env.ref("base.EUR").id
self.env.ref('base.main_company').write({'currency_id': self.currency_euro_id})
- self.account_rcv = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1)
- self.account_rsa = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1)
+ self.account_rcv = partner_agrolait.property_account_receivable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1)
+ self.account_rsa = partner_agrolait.property_account_payable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1)
self.product = self.env.ref("product.product_product_4")
self.bank_journal_euro = self.env['account.journal'].create({'name': 'Bank', 'type': 'bank', 'code': 'BNK67'})
diff --git a/addons/crm/models/crm_team.py b/addons/crm/models/crm_team.py
index 3fe31131944..cddad92eea1 100644
--- a/addons/crm/models/crm_team.py
+++ b/addons/crm/models/crm_team.py
@@ -204,8 +204,11 @@ class Team(models.Model):
def _get_graph(self):
graph_datas = super(Team, self)._get_graph()
if self.dashboard_graph_model == 'crm.opportunity.report' and self.dashboard_graph_group_pipeline == 'stage':
- stage_data = self.env['crm.stage'].browse([d['label'] for d in graph_datas[0]['values']]).read(['sequence', 'name'])
+ stage_ids = [d['label'] for d in graph_datas[0]['values'] if d['label'] is not None]
+ stage_data = self.env['crm.stage'].browse(stage_ids).read(['sequence', 'name'])
stage_data = {d['id']: {'name': d['name'], 'sequence': d['sequence']} for d in stage_data}
+ # use "Undefined" stage for unset stage records
+ stage_data[None] = {'name': _('Undefined'), 'sequence': -1}
graph_datas[0]['values'] = sorted(graph_datas[0]['values'], key=lambda el: stage_data[el['label']]['sequence'])
for gdata in graph_datas[0]['values']:
gdata['label'] = stage_data[gdata['label']]['name']
diff --git a/addons/hr_attendance/models/hr_attendance.py b/addons/hr_attendance/models/hr_attendance.py
index 76d442a4b67..1e43f98cd69 100644
--- a/addons/hr_attendance/models/hr_attendance.py
+++ b/addons/hr_attendance/models/hr_attendance.py
@@ -16,7 +16,8 @@ class HrAttendance(models.Model):
return self.env['hr.employee'].search([('user_id', '=', self.env.uid)], limit=1)
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True)
- department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id")
+ department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
+ readonly=True)
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True)
check_out = fields.Datetime(string="Check Out")
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
diff --git a/addons/hr_payroll/models/hr_contract.py b/addons/hr_payroll/models/hr_contract.py
index 6aebe4134de..00fa9750d12 100644
--- a/addons/hr_payroll/models/hr_contract.py
+++ b/addons/hr_payroll/models/hr_contract.py
@@ -39,16 +39,16 @@ class HrContract(models.Model):
@api.multi
def get_attribute(self, code, attribute):
- return getattr(self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1), attribute)
+ return self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1)[attribute]
@api.multi
def set_attribute_value(self, code, active):
for contract in self:
if active:
value = self.env['hr.contract.advantage.template'].search([('code', '=', code)], limit=1).default_value
- setattr(contract, code, value)
+ contract[code] = value
else:
- setattr(contract, code, 0.0)
+ contract[code] = 0.0
class HrContractAdvandageTemplate(models.Model):
diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py
index c7fb34607ed..265309f8d34 100644
--- a/addons/payment/controllers/portal.py
+++ b/addons/payment/controllers/portal.py
@@ -16,6 +16,7 @@ class WebsitePayment(http.Controller):
values = {
'pms': payment_tokens,
'acquirers': acquirers,
+ 'error_message': [kwargs['error']] if kwargs.get('error') else False,
'return_url': return_url,
'bootstrap_formatting': True,
'partner_id': partner.id
diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py
index 8ccdfc935f9..61c1f003b59 100644
--- a/addons/payment_stripe/controllers/main.py
+++ b/addons/payment_stripe/controllers/main.py
@@ -21,8 +21,18 @@ class StripeController(http.Controller):
def stripe_s2s_create(self, **post):
acquirer_id = int(post.get('acquirer_id'))
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
- acquirer.s2s_process(post)
- return werkzeug.utils.redirect(post.get('return_url', '/'))
+ error = None
+ try:
+ acquirer.s2s_process(post)
+ except Exception as e:
+ error = e.message
+
+ return_url = post.get('return_url', '/')
+ if error:
+ separator = '?' if werkzeug.urls.url_parse(return_url).query == '' else '&'
+ return_url += '{}{}'.format(separator, werkzeug.urls.url_encode({'error': error}))
+
+ return werkzeug.utils.redirect(return_url)
@http.route(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False)
def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs):
@@ -63,7 +73,19 @@ class StripeController(http.Controller):
tx = TX.sudo().browse(int(tx_id))
if not tx:
raise werkzeug.exceptions.NotFound()
- response = tx._create_stripe_charge(tokenid=post['tokenid'], email=post['email'])
+
+ stripe_token = post['token']
+ response = None
+ if tx.type == 'form_save' and tx.partner_id:
+ payment_token_id = request.env['payment.token'].sudo().create({
+ 'acquirer_id': tx.acquirer_id.id,
+ 'partner_id': tx.partner_id.id,
+ 'stripe_token': stripe_token
+ })
+ tx.payment_token_id = payment_token_id
+ response = tx._create_stripe_charge(acquirer_ref=payment_token_id.acquirer_ref, email=stripe_token['email'])
+ else:
+ response = tx._create_stripe_charge(tokenid=stripe_token['id'], email=stripe_token['email'])
_logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response))
if response:
request.env['payment.transaction'].sudo().with_context(lang=None).form_feedback(response, 'stripe')
diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py
index 232ec6d9d43..76b8e3aebf7 100644
--- a/addons/payment_stripe/models/payment.py
+++ b/addons/payment_stripe/models/payment.py
@@ -2,6 +2,7 @@
import logging
import requests
+import pprint
from odoo import api, fields, models, _
from odoo.addons.payment.models.payment_acquirer import ValidationError
@@ -227,11 +228,12 @@ class PaymentTokenStripe(models.Model):
@api.model
def stripe_create(self, values):
- res = {}
+ token = values.get('stripe_token')
+ description = None
payment_acquirer = self.env['payment.acquirer'].browse(values.get('acquirer_id'))
- url_token = 'https://%s/tokens' % payment_acquirer._get_stripe_api_url()
- url_customer = 'https://%s/customers' % payment_acquirer._get_stripe_api_url()
+ # when asking to create a token on Stripe servers
if values.get('cc_number'):
+ url_token = 'https://%s/tokens' % payment_acquirer._get_stripe_api_url()
payment_params = {
'card[number]': values['cc_number'].replace(' ', ''),
'card[exp_month]': str(values['cc_expiry'][:2]),
@@ -244,27 +246,56 @@ class PaymentTokenStripe(models.Model):
params=payment_params,
headers=STRIPE_HEADERS)
token = r.json()
- if token.get('id'):
- customer_params = {
- 'source': token['id'],
- 'description': values['cc_holder_name']
- }
- if values.get('partner_id'):
- partner = self.env['res.partner'].browse(values['partner_id'])
- customer_params['email'] = partner.email and partner.email.strip()
- r = requests.post(url_customer,
- auth=(payment_acquirer.stripe_secret_key, ''),
- params=customer_params,
- headers=STRIPE_HEADERS)
- customer = r.json()
- res = {
- 'acquirer_ref': customer['id'],
- 'name': 'XXXXXXXXXXXX%s - %s' % (values['cc_number'][-4:], values['cc_holder_name'])
- }
- elif token.get('error'):
- raise UserError(token['error']['message'])
+ description = values['cc_holder_name']
+ else:
+ partner_id = self.env['res.partner'].browse(values['partner_id'])
+ description = 'Partner: %s (id: %s)' % (partner_id.name, partner_id.id)
+
+ if not token:
+ raise Exception('stripe_create: No token provided!')
+
+ res = self._stripe_create_customer(token, description, payment_acquirer.id)
# pop credit card info to info sent to create
- for field_name in ["cc_number", "cvc", "cc_holder_name", "cc_expiry", "cc_brand"]:
+ for field_name in ["cc_number", "cvc", "cc_holder_name", "cc_expiry", "cc_brand", "stripe_token"]:
values.pop(field_name, None)
return res
+
+
+ def _stripe_create_customer(self, token, description=None, acquirer_id=None):
+ if token['object'] != 'token':
+ _logger.error('payment.token.stripe_create_customer: Cannot create a customer for object type "%s"', token.get('object'))
+ raise Exception('We are unable to process your credit card information.')
+
+ if token['type'] != 'card':
+ _logger.error('payment.token.stripe_create_customer: Cannot create a customer for token type "%s"', token.get('type'))
+ raise Exception('We are unable to process your credit card information.')
+
+ if token.get('error'):
+ _logger.error('payment.token.stripe_create_customer: Token error:\n%s', pprint.pformat(token['error']))
+ raise Exception(token['error']['message'])
+
+ payment_acquirer = self.env['payment.acquirer'].browse(acquirer_id or self.acquirer_id.id)
+ url_customer = 'https://%s/customers' % payment_acquirer._get_stripe_api_url()
+
+ customer_params = {
+ 'source': token['id'],
+ 'description': description or token["card"]["name"]
+ }
+
+ r = requests.post(url_customer,
+ auth=(payment_acquirer.stripe_secret_key, ''),
+ params=customer_params,
+ headers=STRIPE_HEADERS)
+ customer = r.json()
+
+ if customer.get('error'):
+ _logger.error('payment.token.stripe_create_customer: Customer error:\n%s', pprint.pformat(customer['error']))
+ raise Exception(customer['error']['message'])
+
+ values = {
+ 'acquirer_ref': customer['id'],
+ 'name': 'XXXXXXXXXXXX%s - %s' % (token['card']['last4'], customer_params["description"])
+ }
+
+ return values
diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js
index 1dfe867d196..7a6d1447f87 100644
--- a/addons/payment_stripe/static/src/js/stripe.js
+++ b/addons/payment_stripe/static/src/js/stripe.js
@@ -38,8 +38,9 @@ odoo.define('payment_stripe.stripe', function(require) {
});
}
ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
- tokenid: token.id,
- email: token.email,
+ tokenid: token.id, // TBE TODO: for backward compatibility, remove on master
+ email: token.email, // TBE TODO: for backward compatibility, remove on master
+ token: token,
amount: $("input[name='amount']").val(),
acquirer_id: $("#acquirer_stripe").val(),
currency: $("input[name='currency']").val(),
diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py
index fd150d96a61..e7ce561d56f 100644
--- a/addons/point_of_sale/models/pos_order.py
+++ b/addons/point_of_sale/models/pos_order.py
@@ -1030,6 +1030,7 @@ class ReportSaleDetails(models.AbstractModel):
payments = []
return {
+ 'currency_precision': user_currency.decimal_places,
'total_paid': user_currency.round(total),
'payments': payments,
'company_name': self.env.user.company_id.name,
diff --git a/addons/point_of_sale/static/src/js/devices.js b/addons/point_of_sale/static/src/js/devices.js
index 0317ad5e16d..bf11db31bf2 100644
--- a/addons/point_of_sale/static/src/js/devices.js
+++ b/addons/point_of_sale/static/src/js/devices.js
@@ -5,6 +5,7 @@ var core = require('web.core');
var mixins = require('web.mixins');
var rpc = require('web.rpc');
var Session = require('web.Session');
+var PosBaseWidget = require('point_of_sale.BaseWidget');
var QWeb = core.qweb;
var _t = core._t;
@@ -447,6 +448,7 @@ var ProxyDevice = core.Class.extend(mixins.PropertiesMixin,{
})
.then(function(result){
var env = {
+ widget: new PosBaseWidget(self),
company: self.pos.company,
pos: self.pos,
products: result.products,
diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js
index 71b3b655eb7..088f62fbeeb 100644
--- a/addons/point_of_sale/static/src/js/screens.js
+++ b/addons/point_of_sale/static/src/js/screens.js
@@ -1022,6 +1022,9 @@ var ProductScreenWidget = ScreenWidget.extend({
this.product_categories_widget.reset_category();
this.numpad.state.reset();
}
+ if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) {
+ this.chrome.widget.keyboard.connect($(this.el.querySelector('.searchbox input')));
+ }
},
close: function(){
@@ -1375,6 +1378,7 @@ var ClientListScreenWidget = ScreenWidget.extend({
// to maintain consistent scroll.
display_client_details: function(visibility,partner,clickpos){
var self = this;
+ var searchbox = this.$('.searchbox input');
var contents = this.$('.client-details-contents');
var parent = this.$('.client-list').parent();
var scroll = parent.scrollTop();
@@ -1411,6 +1415,19 @@ var ClientListScreenWidget = ScreenWidget.extend({
this.details_visible = true;
this.toggle_save_button();
} else if (visibility === 'edit') {
+ // Connect the keyboard to the edited field
+ if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) {
+ contents.off('click', '.detail');
+ searchbox.off('click');
+ contents.on('click', '.detail', function(ev){
+ self.chrome.widget.keyboard.connect(ev.target);
+ self.chrome.widget.keyboard.show();
+ });
+ searchbox.on('click', function() {
+ self.chrome.widget.keyboard.connect($(this));
+ });
+ }
+
this.editing_client = true;
contents.empty();
contents.append($(QWeb.render('ClientDetailsEdit',{widget:this,partner:partner})));
@@ -1453,6 +1470,9 @@ var ClientListScreenWidget = ScreenWidget.extend({
},
close: function(){
this._super();
+ if (this.pos.config.iface_vkeyboard && this.chrome.widget.keyboard) {
+ this.chrome.widget.keyboard.hide();
+ }
},
});
gui.define_screen({name:'clientlist', widget: ClientListScreenWidget});
diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml
index 84648f58eff..ec35d6a65ca 100644
--- a/addons/point_of_sale/static/src/xml/pos.xml
+++ b/addons/point_of_sale/static/src/xml/pos.xml
@@ -941,7 +941,7 @@