1 /*jshint indent:2, curly:true, eqeqeq:true, immed:true, latedef:true,
2 newcap:true, noarg:true, regexp:true, undef:true, strict:true, trailing:true,
4 /*global Globalize:true, XT:true, XM:true, Backbone:true, _:true,
5 console:true, async:true, window:true */
11 This should only be called by `calculatePrice`.
14 var _calculatePrice = function (model) {
15 var K = model.getClass(),
16 item = model.get("item"),
17 site = model.get("site"),
18 priceUnit = model.get("priceUnit"),
19 quantity = model.get(model.altQuantityAttribute),
20 quantityUnit = model.get("quantityUnit"),
21 readOnlyCache = model.isReadOnly("price"),
22 parent = model.getParent(),
23 asOf = parent.get(parent.documentDateKey),
30 // Set price after we have item and all characteristics prices
31 setPrice = function () {
32 // Allow editing again if we could before
33 model.setReadOnly("price", readOnlyCache);
35 // If price was requested before this response,
36 // then bail out and start over
37 if (model._invalidPriceRequest) {
38 delete model._invalidPriceRequest;
39 delete model._pendingPriceRequest;
40 _calculatePrice(model);
44 var totalPrice = XT.math.add(prices, XT.SALES_PRICE_SCALE);
45 model.set({price: totalPrice});
46 model.setIfExists({customerPrice: totalPrice});
47 model.calculateExtendedPrice();
50 parentDate = parent.get(parent.documentDateKey);
51 customer = parent.get("customer");
52 currency = parent.get("currency");
54 // If we already have a request pending we need to indicate
55 // when that is done to start over because something has changed.
56 if (model._pendingPriceRequest) {
57 if (!model._invalidPriceRequest) {
58 model._invalidPriceRequest = true;
63 // Don't allow user editing of price until we hear back from the server
64 model.setReadOnly("price", true);
67 itemOptions.asOf = asOf;
68 itemOptions.currency = currency;
69 itemOptions.effective = parentDate;
70 itemOptions.site = site;
71 itemOptions.error = function (err) {
72 model.trigger("invalid", err);
75 itemOptions.quantityUnit = quantityUnit;
76 itemOptions.priceUnit = priceUnit;
77 itemOptions.success = function (resp) {
78 // Handle no price found scenario
79 if (resp.price === -9999 && !model._invalidPriceRequest) {
80 model.notify("_noPriceFound".loc(), { type: K.WARNING });
81 model.unset("customerPrice");
83 model.unset(model.altQuantityAttribute);
84 model.unset("quantity");
86 // Handle normal scenario
88 if (!model._invalidPriceRequest) {
89 //model.set("basePrice", resp.price);
90 prices.push(resp.price);
95 itemOptions.error = function (err) {
96 model.trigger("error", err);
98 customer.itemPrice(item, quantity, itemOptions);
102 Function that actually does the calculation work.
103 Taken largely from sales_order_base.
106 var _calculateTotals = function (model) {
107 var miscCharge = model.get("miscCharge") || 0.0,
108 scale = XT.MONEY_SCALE,
110 subtract = XT.math.subtract,
113 lineItemTaxDetails = [],
114 adjustmentTaxDetails = [],
121 model.meta.get("taxes").reset([]);
123 // Collect line item detail
124 var forEachLineItemFunction = function (lineItem) {
125 var extPrice = lineItem.get('extendedPrice') || 0,
126 quantity = lineItem.get("quantity") || 0;
128 subtotals.push(extPrice);
129 taxDetails = taxDetails.concat(lineItem.get("taxes").models);
130 lineItemTaxDetails = lineItemTaxDetails.concat(lineItem.get("taxes").models);
133 // Collect tax adjustment detail
134 var forEachTaxAdjustmentFunction = function (taxAdjustment) {
135 taxDetails = taxDetails.concat(taxAdjustment);
136 adjustmentTaxDetails = adjustmentTaxDetails.concat(taxAdjustment);
139 // Line items should not include deleted.
140 var lineItems = _.filter(model.get("lineItems").models, function (item) {
141 return item.status !== XM.Model.DESTROYED_DIRTY;
144 _.each(lineItems, forEachLineItemFunction);
145 _.each(model.get('taxAdjustments').models, forEachTaxAdjustmentFunction);
148 // Subtotal the tax detail for presentation in the view layer. The presentation
149 // of the taxes are grouped first by line item / adjustment / freight, as opposed
150 // to the rest of the calculation here which are first grouped by taxCode. So
151 // the calculation has to be separate.
153 taxCodes = _.groupBy(lineItemTaxDetails, function (detail) {
154 return detail.getValue("taxCode.code");
156 _.each(taxCodes, function (taxDetails, code) {
157 var subtotal = _.reduce(taxDetails, function (memo, item) {
158 return memo + item.get("amount");
160 taxModel = new XM.StaticModel({
161 type: "_lineItems".loc(),
163 currency: model.get("currency"),
166 model.meta.get("taxes").add(taxModel);
170 // First group amounts by tax code
171 taxCodes = _.groupBy(taxDetails, function (detail) {
172 return detail.getValue("taxCode.code");
175 // Loop through each tax code group and subtotal
176 _.each(taxCodes, function (group, key) {
180 // Collect array of taxes
181 _.each(group, function (detail) {
182 taxes.push(detail.get("amount"));
185 // Subtotal first to make sure we round by subtotal
186 subtotal = add(taxes, 6);
188 // Now add to tax grand total
189 taxTotal = add(taxTotal, subtotal, scale);
192 // Totaling calculations
193 // First get additional subtotal attributes (i.e. freight) that were added outside of core
194 if (model.extraSubtotalFields && model.extraSubtotalFields.length) {
195 _.each(model.extraSubtotalFields, function (attr) {
196 var attrVal = model.get(attr);
197 subtotals.push(attrVal);
201 subtotal = add(subtotals, scale);
202 subtotals = subtotals.concat([miscCharge, taxTotal]);
203 total = add(subtotals, scale);
206 model.set({subtotal: subtotal, taxTotal: taxTotal, total: total});
207 model.trigger("refreshView", model);
215 bindEvents: function (attributes, options) {
216 XM.Document.prototype.bindEvents.apply(this, arguments);
217 this.on("change:customer", this.customerDidChange);
218 this.on('add:lineItems remove:lineItems', this.lineItemsDidChange);
219 this.on("change:" + this.documentDateKey + " add:taxAdjustments", this.setTaxAllocationDate);
220 this.on("change:" + this.documentDateKey + " change:currency", this.calculateOutstandingCredit);
221 this.on("change:" + this.documentDateKey + " change:currency", this.calculateAuthorizedCredit);
222 this.on("change:" + this.documentDateKey + " add:allocations remove:allocations",
223 this.calculateAllocatedCredit);
224 this.on("add:lineItems remove:lineItems change:lineItems change:subtotal" +
225 "change:taxTotal change:miscCharge", this.calculateTotals);
226 this.on("change:taxZone add:taxAdjustments remove:taxAdjustments", this.calculateTotalTax);
227 this.on("change:taxZone", this.recalculateTaxes);
228 this.on("change:total change:allocatedCredit change:outstandingCredit",
229 this.calculateBalance);
230 this.on('allocatedCredit', this.allocatedCreditDidChange);
231 this.on('statusChange', this.statusDidChange);
234 initialize: function (attributes, options) {
235 XM.Document.prototype.initialize.apply(this, arguments);
236 this.meta = new Backbone.Model();
237 this.meta.set({taxes: new Backbone.Collection()});
241 // Model-specific functions
243 allocatedCreditDidChange: function () {
244 this.setCurrencyReadOnly();
247 // Refactor potential: sales_order_base minus shipto stuff minus prospect stuff
248 applyCustomerSettings: function () {
249 var customer = this.get("customer"),
250 isFreeFormBillto = customer ? customer.get("isFreeFormBillto") : false;
252 this.setReadOnly("lineItems", !customer);
254 // Set read only state for free form billto
255 this.setReadOnly(this.billtoAttrArray, !isFreeFormBillto);
258 applyIsPostedRules: function () {
259 var isPosted = this.get("isPosted");
261 this.setReadOnly(["lineItems", "number", this.documentDateKey, "salesRep", "commission",
262 "taxZone", "saleType", "taxAdjustments"], isPosted);
264 if (_.contains(this.getAttributeNames(), "terms")) {
265 this.setReadOnly("terms", isPosted);
270 Add up the allocated credit. Only complicated because the reduce has
271 to happen asynchronously due to currency conversion
273 calculateAllocatedCredit: function () {
274 var invoiceCurrency = this.get("currency"),
276 allocationsWithCurrency = _.filter(this.get("allocations").models, function (allo) {
277 return allo.get("currency");
279 reduceFunction = function (memo, allocationModel, callback) {
280 allocationModel.get("currency").toCurrency(
282 allocationModel.get("amount"),
285 success: function (targetValue) {
286 callback(null, memo + targetValue);
291 finish = function (err, totalAllocatedCredit) {
292 that.set("allocatedCredit", totalAllocatedCredit);
295 async.reduce(allocationsWithCurrency, 0, reduceFunction, finish);
298 calculateAuthorizedCredit: function () {
300 success = function (resp) {
301 that.set({authorizedCredit: resp});
302 that.calculateBalance();
305 this.dispatch("XM.Invoice", "authorizedCredit", [this.id], {success: success});
308 calculateBalance: function () {
309 var rawBalance = this.get("total") -
310 this.get("allocatedCredit") -
311 this.get("authorizedCredit") -
312 this.get("outstandingCredit"),
313 balance = Math.max(0, rawBalance);
315 this.set({balance: balance});
318 calculateOutstandingCredit: function () {
320 success = function (resp) {
321 that.set({outstandingCredit: resp});
323 error = function (resp) {
324 // not a valid request
325 that.set({outstandingCredit: null});
328 if (!this.get("customer")) {
329 // don't bother if there's no customer
333 this.dispatch("XM.Invoice", "outstandingCredit",
334 [this.getValue("customer.number"),
335 this.getValue("currency.abbreviation"),
336 this.getValue(this.documentDateKey)],
337 {success: success, error: error});
340 calculateTotals: function () {
341 _calculateTotals(this);
344 // XXX just calculate all the totals
345 calculateTotalTax: function () {
346 this.calculateTotals();
349 // Refactor potential: taken largely from sales_order_base
350 customerDidChange: function (model, value, options) {
351 var customer = this.get("customer"),
352 billtoContact = customer && customer.get("billingContact"),
353 billtoAddress = billtoContact && billtoContact.get("address"),
356 unsetBilltoAddress = function () {
357 that.unset("billtoName")
358 .unset("billtoAddress1")
359 .unset("billtoAddress2")
360 .unset("billtoAddress3")
362 .unset("billtoState")
363 .unset("billtoPostalCode")
364 .unset("billtoCountry");
367 this.applyCustomerSettings();
369 // Set customer default data
372 billtoName: customer.get("name"),
373 salesRep: customer.get("salesRep"),
374 commission: customer.get("commission"),
375 taxZone: customer.get("taxZone"),
376 currency: customer.get("currency") || this.get("currency")
378 if (_.contains(this.getAttributeNames(), "terms")) {
379 billtoAttrs.terms = customer.get("terms");
381 if (_.contains(this.getAttributeNames(), "billtoPhone")) {
382 billtoAttrs.billtoPhone = billtoContact && billtoContact.getValue("phone");
385 _.extend(billtoAttrs, {
386 billtoAddress1: billtoAddress.getValue("line1"),
387 billtoAddress2: billtoAddress.getValue("line2"),
388 billtoAddress3: billtoAddress.getValue("line3"),
389 billtoCity: billtoAddress.getValue("city"),
390 billtoState: billtoAddress.getValue("state"),
391 billtoPostalCode: billtoAddress.getValue("postalCode"),
392 billtoCountry: billtoAddress.getValue("country"),
395 unsetBilltoAddress();
397 this.set(billtoAttrs);
399 unsetBilltoAddress();
400 this.unset("salesRep")
406 .unset("billtoPhone");
411 lineItemsDidChange: function () {
412 var lineItems = this.get("lineItems");
413 this.setCurrencyReadOnly();
414 this.setReadOnly("customer", lineItems.length > 0);
418 Re-evaluate taxes for all line items
420 recalculateTaxes: function () {
421 _.each(this.get("lineItems").models, function (lineItem) {
422 lineItem.calculateTax();
427 Set the currency read-only if there is allocated credit OR line items.
428 I believe SalesOrderBase has a bug in not considering both these
429 conditions at the same time.
431 setCurrencyReadOnly: function () {
432 var lineItems = this.get("lineItems");
433 this.setReadOnly("currency", lineItems.length > 0 || this.get("allocatedCredit"));
437 The document date on any misc tax adjustments should be the invoice date
439 setTaxAllocationDate: function () {
440 var documentDate = this.get(this.documentDateKey);
441 _.each(this.get("taxAdjustments").models, function (taxAdjustment) {
442 taxAdjustment.set({documentDate: documentDate});
446 statusDidChange: function () {
447 var status = this.getStatus();
448 XM.SalesOrderBase.prototype.statusDidChange.apply(this, arguments);
449 if (status === XM.Model.READY_CLEAN) {
450 this.applyIsPostedRules();
451 this.allocatedCreditDidChange();
455 // very similar to sales order, but minus shipto and prospect checks
456 validate: function () {
457 var customer = this.get("customer"),
458 total = this.get("total"),
459 lineItems = this.get("lineItems"),
463 error = XM.Document.prototype.validate.apply(this, arguments);
464 if (error) { return error; }
467 return XT.Error.clone('xt2011');
470 // Check for line items has to consider models that
471 // are marked for deletion, but not yet saved.
472 // The prevStatus is used because the current
473 // status is BUSY_COMMITTING once save has begun.
474 validItems = _.filter(lineItems.models, function (item) {
475 return item._prevStatus !== XM.Model.DESTROYED_DIRTY;
478 if (!validItems.length) {
479 return XT.Error.clone('xt2012');
492 XM.Invoice = XM.Document.extend(_.extend({}, XM.InvoiceMixin, {
493 /** @scope XM.Invoice.prototype */
498 recordType: 'XM.Invoice',
500 documentKey: 'number',
502 documentDateKey: 'invoiceDate',
504 altQuantityAttribute: 'billed',
506 idAttribute: 'number',
508 numberPolicySetting: 'InvcNumberGeneration',
510 extraSubtotalFields: [],
512 defaults: function () {
514 invoiceDate: new Date(),
529 readOnlyAttributes: [
543 // like sales order, minus contact info
563 XM.InvoiceTax = XM.Model.extend({
564 /** @scope XM.InvoiceTax.prototype */
566 recordType: 'XM.InvoiceTax',
570 // make up the the field that is "value"'ed in the ORM
571 taxType: "Adjustment",
573 bindEvents: function (attributes, options) {
574 XM.Model.prototype.bindEvents.apply(this, arguments);
575 this.on("change:amount", this.calculateTotalTax);
578 calculateTotalTax: function () {
579 var parent = this.getParent();
581 parent.calculateTotalTax();
592 XM.InvoiceAllocation = XM.Model.extend({
593 /** @scope XM.InvoiceAllocation.prototype */
595 recordType: 'XM.InvoiceAllocation',
606 XM.InvoiceListItem = XM.Info.extend({
607 /** @scope XM.InvoiceListItem.prototype */
609 recordType: 'XM.InvoiceListItem',
611 editableModel: 'XM.Invoice',
613 documentDateKey: 'invoiceDate',
615 couldDestroy: function (callback) {
616 callback(!this.get("isPosted"));
619 canPost: function (callback) {
620 callback(!this.get("isPosted"));
623 canVoid: function (callback) {
624 var response = this.get("isPosted");
625 callback(response || false);
628 doPost: function (options) {
629 this.dispatch("XM.Invoice", "post", [this.id], {
630 success: options && options.success,
631 error: options && options.error
635 doVoid: function (options) {
636 this.dispatch("XM.Invoice", "void", [this.id], {
637 success: options && options.success,
638 error: options && options.error
649 XM.InvoiceRelation = XM.Info.extend({
650 /** @scope XM.InvoiceRelation.prototype */
652 recordType: 'XM.InvoiceRelation',
654 editableModel: 'XM.Invoice'
661 @extends XM.Characteristic
663 XM.InvoiceCharacteristic = XM.CharacteristicAssignment.extend({
664 /** @scope XM.InvoiceCharacteristic.prototype */
666 recordType: 'XM.InvoiceCharacteristic',
672 XM.InvoiceLineMixin = {
676 sellingUnits: undefined,
678 readOnlyAttributes: [
688 bindEvents: function (attributes, options) {
689 XM.Model.prototype.bindEvents.apply(this, arguments);
690 this.on("change:item", this.itemDidChange);
691 this.on("change:" + this.altQuantityAttribute, this.quantityChanged);
692 this.on('change:price', this.priceDidChange);
693 this.on('change:priceUnit', this.priceUnitDidChange);
694 this.on('change:quantityUnit', this.quantityUnitDidChange);
695 this.on('change:' + this.parentKey, this.parentDidChange);
696 this.on('change:taxType', this.calculateTax);
697 this.on('change:isMiscellaneous', this.isMiscellaneousDidChange);
699 this.isMiscellaneousDidChange();
702 initialize: function (attributes, options) {
703 XM.Model.prototype.initialize.apply(this, arguments);
704 this.sellingUnits = new XM.UnitCollection();
708 // Model-specific functions
711 // XXX with the uncommented stuff back in, it's identical to the one in salesOrderBase
713 Calculates and sets the extended price.
715 returns {Object} Receiver
717 calculateExtendedPrice: function () {
718 var billed = this.get(this.altQuantityAttribute) || 0,
719 quantityUnitRatio = this.get("quantityUnitRatio"),
720 priceUnitRatio = this.get("priceUnitRatio"),
721 price = this.get("price") || 0,
722 extPrice = (billed * quantityUnitRatio / priceUnitRatio) * price;
723 extPrice = XT.toExtendedPrice(extPrice);
724 this.set("extendedPrice", extPrice);
726 this.recalculateParent();
731 Calculate the price for this line item
733 @param{Boolean} force - force the net price to update, even if settings indicate not to.
734 @returns {Object} Receiver
736 calculatePrice: function (force) {
737 var settings = XT.session.settings,
740 canUpdate = this.canUpdate(),
741 item = this.get("item"),
742 priceUnit = this.get("priceUnit"),
743 priceUnitRatio = this.get("priceUnitRatio"),
744 quantity = this.get(this.altQuantityAttribute),
745 quantityUnit = this.get("quantityUnit"),
746 updatePolicy = settings.get("UpdatePriceLineEdit"),
747 parent = this.getParent(),
748 customer = parent ? parent.get("customer") : false,
749 currency = parent ? parent.get("currency") :false,
752 // If no parent, don't bother
753 if (!parent) { return; }
755 // Make sure we have necessary values
756 if (canUpdate && customer && currency &&
757 item && quantity && quantityUnit &&
758 priceUnit && priceUnitRatio &&
759 parent.get(parent.documentDateKey)) {
761 _calculatePrice(this);
766 calculateTax: function () {
767 var parent = this.getParent(),
768 amount = this.get("extendedPrice"),
769 taxTypeId = this.getValue("taxType.id"),
781 // If no parent, don't bother
782 if (!parent) { return; }
784 recordType = parent.recordType;
785 taxZoneId = parent.getValue("taxZone.id");
786 effective = parent.get(parent.documentDateKey);
787 currency = parent.get("currency");
789 if (effective && currency && amount) {
790 params = [taxZoneId, taxTypeId, effective, currency.id, amount];
791 processTaxResponses = function (responses) {
792 var processTaxResponse = function (resp, callback) {
793 var setTaxModel = function () {
794 taxModel.off("change:uuid", setTaxModel);
796 taxType: that.get("taxType"),
797 taxCode: resp.taxCode.code,
800 that.get("taxes").add(taxModel);
801 taxesTotal.push(resp.tax);
804 var taxModel = new XM.InvoiceLineTax();
805 taxModel.on("change:uuid", setTaxModel);
806 taxModel.initialize(null, {isNew: true});
808 var finish = function () {
809 that.recalculateParent(false);
811 that.get("taxes").reset(); // empty it out so we can populate it
813 async.map(responses, processTaxResponse, finish);
814 taxTotal = XT.math.add(taxesTotal, XT.COST_SCALE);
815 that.set("taxTotal", taxTotal);
817 options.success = processTaxResponses;
818 this.dispatch("XM.Tax", "taxDetail", params, options);
822 isMiscellaneousDidChange: function () {
823 var isMisc = this.get("isMiscellaneous");
825 //this.set({item: null}); ???
826 this.setReadOnly("item", true);
827 this.setReadOnly("itemNumber", false);
828 this.setReadOnly("itemDescription", false);
829 this.setReadOnly("salesCategory", false);
831 //this.set({itemNumber: null, itemDescription: null, salesCategory: null}); ???
832 this.setReadOnly("item", false);
833 this.setReadOnly("itemNumber", true);
834 this.setReadOnly("itemDescription", true);
835 this.setReadOnly("salesCategory", true);
839 // refactor potential: this function is largely similar to the one on XM.SalesOrderLine
840 itemDidChange: function () {
841 //var isWholesaleCost = XT.session.settings.get("WholesalePriceCosting"), not using this
844 parent = this.getParent(),
845 taxZone = parent && parent.get("taxZone"),
846 item = this.get("item"),
847 unitCost = item && item.get("standardCost");
850 this.unset("priceUnitRatio");
851 this.unset("taxType");
852 this.fetchSellingUnits();
854 if (!item) { return; }
856 // Fetch and update tax type
857 options.success = function (id) {
858 var taxType = XM.taxTypes.get(id);
860 that.set("taxType", taxType);
862 that.unset("taxType");
866 item.taxType(taxZone, options);
868 this.calculatePrice();
870 //Refactor potential: this is similar to sales order line item, but
871 // skips the scheduleDate calculations
872 parentDidChange: function () {
873 var parent = this.getParent(),
874 lineNumber = this.get("lineNumber"),
878 // Set next line number to be 1 more than the highest living model
879 if (parent && !lineNumber) {
880 lineNumberArray = _.compact(_.map(parent.get("lineItems").models, function (model) {
881 return model.isDestroyed() ? null : model.get("lineNumber");
883 maxLineNumber = lineNumberArray.length > 0 ? Math.max.apply(null, lineNumberArray) : 0;
884 this.set("lineNumber", maxLineNumber + 1);
888 priceDidChange: function () {
889 this.calculateExtendedPrice();
899 XM.InvoiceLine = XM.Model.extend(_.extend({}, XM.OrderLineMixin, XM.InvoiceLineMixin, {
900 /** @scope XM.InvoiceLine.prototype */
905 recordType: 'XM.InvoiceLine',
907 parentKey: "invoice",
909 altQuantityAttribute: "billed",
911 defaults: function () {
913 site: XT.defaultSite(),
914 isMiscellaneous: false
925 XM.InvoiceLineTax = XM.Model.extend({
926 /** @scope XM.InvoiceLineTax.prototype */
928 recordType: 'XM.InvoiceLineTax',
939 XM.InvoiceContact = XM.Model.extend({
940 /** @scope XM.InvoiceContact.prototype */
942 recordType: 'XM.InvoiceContact',
944 isDocumentAssignment: true
953 XM.InvoiceAccount = XM.Model.extend({
954 /** @scope XM.InvoiceAccount.prototype */
956 recordType: 'XM.InvoiceAccount',
958 isDocumentAssignment: true
967 XM.InvoiceCustomer = XM.Model.extend({
968 /** @scope XM.InvoiceCustomer.prototype */
970 recordType: 'XM.InvoiceCustomer',
972 isDocumentAssignment: true
981 XM.InvoiceFile = XM.Model.extend({
982 /** @scope XM.InvoiceFile.prototype */
984 recordType: 'XM.InvoiceFile',
986 isDocumentAssignment: true
995 XM.InvoiceUrl = XM.Model.extend({
996 /** @scope XM.InvoiceUrl.prototype */
998 recordType: 'XM.InvoiceUrl',
1000 isDocumentAssignment: true
1009 XM.InvoiceItem = XM.Model.extend({
1010 /** @scope XM.InvoiceItem.prototype */
1012 recordType: 'XM.InvoiceItem',
1014 isDocumentAssignment: true
1021 // ..........................................................
1028 @extends XM.Collection
1030 XM.InvoiceListItemCollection = XM.Collection.extend({
1031 /** @scope XM.InvoiceListItemCollection.prototype */
1033 model: XM.InvoiceListItem
1040 @extends XM.Collection
1042 XM.InvoiceRelationCollection = XM.Collection.extend({
1043 /** @scope XM.InvoiceRelationCollection.prototype */
1045 model: XM.InvoiceRelation