Merge pull request #1609 from xtuple/4_5_x
[xtuple] / enyo-client / application / source / models / invoice.js
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,
3 white:true*/
4 /*global Globalize:true, XT:true, XM:true, Backbone:true, _:true,
5   console:true, async:true, window:true */
6
7 (function () {
8   "use strict";
9
10   /**
11     This should only be called by `calculatePrice`.
12     @private
13   */
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),
24       prices = [],
25       itemOptions = {},
26       parentDate,
27       customer,
28       currency,
29
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);
34
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);
41           return;
42         }
43
44         var totalPrice = XT.math.add(prices, XT.SALES_PRICE_SCALE);
45         model.set({price: totalPrice});
46         model.setIfExists({customerPrice: totalPrice});
47         model.calculateExtendedPrice();
48       };
49
50     parentDate = parent.get(parent.documentDateKey);
51     customer = parent.get("customer");
52     currency = parent.get("currency");
53
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;
59       }
60       return;
61     }
62
63     // Don't allow user editing of price until we hear back from the server
64     model.setReadOnly("price", true);
65
66     // Get the item price
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);
73     };
74
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");
82         model.unset("price");
83         model.unset(model.altQuantityAttribute);
84         model.unset("quantity");
85
86       // Handle normal scenario
87       } else {
88         if (!model._invalidPriceRequest) {
89           //model.set("basePrice", resp.price);
90           prices.push(resp.price);
91         }
92         setPrice();
93       }
94     };
95     itemOptions.error = function (err) {
96       model.trigger("error", err);
97     };
98     customer.itemPrice(item, quantity, itemOptions);
99   };
100
101   /**
102     Function that actually does the calculation work.
103     Taken largely from sales_order_base.
104     @private
105   */
106   var _calculateTotals = function (model) {
107     var miscCharge = model.get("miscCharge") || 0.0,
108       scale = XT.MONEY_SCALE,
109       add = XT.math.add,
110       subtract = XT.math.subtract,
111       subtotals = [],
112       taxDetails = [],
113       lineItemTaxDetails = [],
114       adjustmentTaxDetails = [],
115       subtotal,
116       taxTotal = 0.0,
117       taxModel,
118       total,
119       taxCodes;
120
121     model.meta.get("taxes").reset([]);
122
123     // Collect line item detail
124     var forEachLineItemFunction = function (lineItem) {
125       var extPrice = lineItem.get('extendedPrice') || 0,
126         quantity = lineItem.get("quantity") || 0;
127
128       subtotals.push(extPrice);
129       taxDetails = taxDetails.concat(lineItem.get("taxes").models);
130       lineItemTaxDetails = lineItemTaxDetails.concat(lineItem.get("taxes").models);
131     };
132
133     // Collect tax adjustment detail
134     var forEachTaxAdjustmentFunction = function (taxAdjustment) {
135       taxDetails = taxDetails.concat(taxAdjustment);
136       adjustmentTaxDetails = adjustmentTaxDetails.concat(taxAdjustment);
137     };
138
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;
142     });
143
144     _.each(lineItems, forEachLineItemFunction);
145     _.each(model.get('taxAdjustments').models, forEachTaxAdjustmentFunction);
146
147     //
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.
152     //
153     taxCodes = _.groupBy(lineItemTaxDetails, function (detail) {
154       return detail.getValue("taxCode.code");
155     });
156     _.each(taxCodes, function (taxDetails, code) {
157       var subtotal = _.reduce(taxDetails, function (memo, item) {
158         return memo + item.get("amount");
159       }, 0);
160       taxModel = new XM.StaticModel({
161         type: "_lineItems".loc(),
162         code: code,
163         currency: model.get("currency"),
164         amount: subtotal
165       });
166       model.meta.get("taxes").add(taxModel);
167     });
168
169     // Total taxes
170     // First group amounts by tax code
171     taxCodes = _.groupBy(taxDetails, function (detail) {
172       return detail.getValue("taxCode.code");
173     });
174
175     // Loop through each tax code group and subtotal
176     _.each(taxCodes, function (group, key) {
177       var taxes = [],
178         subtotal;
179
180       // Collect array of taxes
181       _.each(group, function (detail) {
182         taxes.push(detail.get("amount"));
183       });
184
185       // Subtotal first to make sure we round by subtotal
186       subtotal = add(taxes, 6);
187
188       // Now add to tax grand total
189       taxTotal = add(taxTotal, subtotal, scale);
190     });
191
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);
198       });
199     }
200
201     subtotal = add(subtotals, scale);
202     subtotals = subtotals.concat([miscCharge, taxTotal]);
203     total = add(subtotals, scale);
204
205     // Set values
206     model.set({subtotal: subtotal, taxTotal: taxTotal, total: total});
207     model.trigger("refreshView", model);
208   };
209
210   XM.InvoiceMixin = {
211
212     //
213     // Core functions
214     //
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);
232     },
233
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()});
238     },
239
240     //
241     // Model-specific functions
242     //
243     allocatedCreditDidChange: function () {
244       this.setCurrencyReadOnly();
245     },
246
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;
251
252       this.setReadOnly("lineItems", !customer);
253
254       // Set read only state for free form billto
255       this.setReadOnly(this.billtoAttrArray, !isFreeFormBillto);
256     },
257
258     applyIsPostedRules: function () {
259       var isPosted = this.get("isPosted");
260
261       this.setReadOnly(["lineItems", "number", this.documentDateKey, "salesRep", "commission",
262         "taxZone", "saleType", "taxAdjustments"], isPosted);
263
264       if (_.contains(this.getAttributeNames(), "terms")) {
265         this.setReadOnly("terms", isPosted);
266       }
267     },
268
269     /**
270       Add up the allocated credit. Only complicated because the reduce has
271       to happen asynchronously due to currency conversion
272     */
273     calculateAllocatedCredit: function () {
274       var invoiceCurrency = this.get("currency"),
275         that = this,
276         allocationsWithCurrency = _.filter(this.get("allocations").models, function (allo) {
277           return allo.get("currency");
278         }),
279         reduceFunction = function (memo, allocationModel, callback) {
280           allocationModel.get("currency").toCurrency(
281             invoiceCurrency,
282             allocationModel.get("amount"),
283             new Date(),
284             {
285               success: function (targetValue) {
286                 callback(null, memo + targetValue);
287               }
288             }
289           );
290         },
291         finish = function (err, totalAllocatedCredit) {
292           that.set("allocatedCredit", totalAllocatedCredit);
293         };
294
295       async.reduce(allocationsWithCurrency, 0, reduceFunction, finish);
296     },
297
298     calculateAuthorizedCredit: function () {
299       var that = this,
300         success = function (resp) {
301           that.set({authorizedCredit: resp});
302           that.calculateBalance();
303         };
304
305       this.dispatch("XM.Invoice", "authorizedCredit", [this.id], {success: success});
306     },
307
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);
314
315       this.set({balance: balance});
316     },
317
318     calculateOutstandingCredit: function () {
319       var that = this,
320         success = function (resp) {
321           that.set({outstandingCredit: resp});
322         },
323         error = function (resp) {
324           // not a valid request
325           that.set({outstandingCredit: null});
326         };
327
328       if (!this.get("customer")) {
329         // don't bother if there's no customer
330         return;
331       }
332
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});
338     },
339
340     calculateTotals: function () {
341       _calculateTotals(this);
342     },
343
344     // XXX just calculate all the totals
345     calculateTotalTax: function () {
346       this.calculateTotals();
347     },
348
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"),
354         billtoAttrs,
355         that = this,
356         unsetBilltoAddress = function () {
357           that.unset("billtoName")
358               .unset("billtoAddress1")
359               .unset("billtoAddress2")
360               .unset("billtoAddress3")
361               .unset("billtoCity")
362               .unset("billtoState")
363               .unset("billtoPostalCode")
364               .unset("billtoCountry");
365         };
366
367       this.applyCustomerSettings();
368
369       // Set customer default data
370       if (customer) {
371         billtoAttrs = {
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")
377         };
378         if (_.contains(this.getAttributeNames(), "terms")) {
379           billtoAttrs.terms = customer.get("terms");
380         }
381         if (_.contains(this.getAttributeNames(), "billtoPhone")) {
382           billtoAttrs.billtoPhone = billtoContact && billtoContact.getValue("phone");
383         }
384         if (billtoAddress) {
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"),
393           });
394         } else {
395           unsetBilltoAddress();
396         }
397         this.set(billtoAttrs);
398       } else {
399         unsetBilltoAddress();
400         this.unset("salesRep")
401             .unset("commission")
402             .unset("terms")
403             .unset("taxZone")
404             .unset("shipVia")
405             .unset("currency")
406             .unset("billtoPhone");
407
408       }
409     },
410
411     lineItemsDidChange: function () {
412       var lineItems = this.get("lineItems");
413       this.setCurrencyReadOnly();
414       this.setReadOnly("customer", lineItems.length > 0);
415     },
416
417     /**
418       Re-evaluate taxes for all line items
419     */
420     recalculateTaxes: function () {
421       _.each(this.get("lineItems").models, function (lineItem) {
422         lineItem.calculateTax();
423       });
424     },
425
426     /**
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.
430     */
431     setCurrencyReadOnly: function () {
432       var lineItems = this.get("lineItems");
433       this.setReadOnly("currency", lineItems.length > 0 || this.get("allocatedCredit"));
434     },
435
436     /**
437       The document date on any misc tax adjustments should be the invoice date
438     */
439     setTaxAllocationDate: function () {
440       var documentDate = this.get(this.documentDateKey);
441       _.each(this.get("taxAdjustments").models, function (taxAdjustment) {
442         taxAdjustment.set({documentDate: documentDate});
443       });
444     },
445
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();
452       }
453     },
454
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"),
460         validItems,
461         error;
462
463       error = XM.Document.prototype.validate.apply(this, arguments);
464       if (error) { return error; }
465
466       if (total < 0) {
467         return XT.Error.clone('xt2011');
468       }
469
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;
476       });
477
478       if (!validItems.length) {
479         return XT.Error.clone('xt2012');
480       }
481
482       return;
483     }
484
485   };
486
487   /**
488     @class
489
490     @extends XM.Document
491   */
492   XM.Invoice = XM.Document.extend(_.extend({}, XM.InvoiceMixin, {
493     /** @scope XM.Invoice.prototype */
494
495     //
496     // Attributes
497     //
498     recordType: 'XM.Invoice',
499
500     documentKey: 'number',
501
502     documentDateKey: 'invoiceDate',
503
504     altQuantityAttribute: 'billed',
505
506     idAttribute: 'number',
507
508     numberPolicySetting: 'InvcNumberGeneration',
509
510     extraSubtotalFields: [],
511
512     defaults: function () {
513       return {
514         invoiceDate: new Date(),
515         isPosted: false,
516         isVoid: false,
517         isPrinted: false,
518         commission: 0,
519         taxTotal: 0,
520         miscCharge: 0,
521         subtotal: 0,
522         total: 0,
523         balance: 0,
524         authorizedCredit: 0
525       
526       };
527     },
528
529     readOnlyAttributes: [
530       "isPosted",
531       "isVoid",
532       "isPrinted",
533       "lineItems",
534       "allocatedCredit",
535       "authorizedCredit",
536       "balance",
537       "status",
538       "subtotal",
539       "taxTotal",
540       "total"
541     ],
542
543     // like sales order, minus contact info
544     billtoAttrArray: [
545       "billtoName",
546       "billtoAddress1",
547       "billtoAddress2",
548       "billtoAddress3",
549       "billtoCity",
550       "billtoState",
551       "billtoPostalCode",
552       "billtoCountry",
553       "billtoPhone",
554     ]
555
556   }));
557
558   /**
559     @class
560
561     @extends XM.Model
562   */
563   XM.InvoiceTax = XM.Model.extend({
564     /** @scope XM.InvoiceTax.prototype */
565
566     recordType: 'XM.InvoiceTax',
567
568     idAttribute: 'uuid',
569
570     // make up the the field that is "value"'ed in the ORM
571     taxType: "Adjustment",
572
573     bindEvents: function (attributes, options) {
574       XM.Model.prototype.bindEvents.apply(this, arguments);
575       this.on("change:amount", this.calculateTotalTax);
576     },
577
578     calculateTotalTax: function () {
579       var parent = this.getParent();
580       if (parent) {
581         parent.calculateTotalTax();
582       }
583     }
584
585   });
586
587   /**
588     @class
589
590     @extends XM.Model
591   */
592   XM.InvoiceAllocation = XM.Model.extend({
593     /** @scope XM.InvoiceAllocation.prototype */
594
595     recordType: 'XM.InvoiceAllocation',
596
597     idAttribute: 'uuid'
598
599   });
600
601   /**
602     @class
603
604     @extends XM.Info
605   */
606   XM.InvoiceListItem = XM.Info.extend({
607     /** @scope XM.InvoiceListItem.prototype */
608
609     recordType: 'XM.InvoiceListItem',
610
611     editableModel: 'XM.Invoice',
612
613     documentDateKey: 'invoiceDate',
614
615     couldDestroy: function (callback) {
616       callback(!this.get("isPosted"));
617     },
618
619     canPost: function (callback) {
620       callback(!this.get("isPosted"));
621     },
622
623     canVoid: function (callback) {
624       var response = this.get("isPosted");
625       callback(response || false);
626     },
627
628     doPost: function (options) {
629       this.dispatch("XM.Invoice", "post", [this.id], {
630         success: options && options.success,
631         error: options && options.error
632       });
633     },
634
635     doVoid: function (options) {
636       this.dispatch("XM.Invoice", "void", [this.id], {
637         success: options && options.success,
638         error: options && options.error
639       });
640     }
641
642   });
643
644   /**
645     @class
646
647     @extends XM.Info
648   */
649   XM.InvoiceRelation = XM.Info.extend({
650     /** @scope XM.InvoiceRelation.prototype */
651
652     recordType: 'XM.InvoiceRelation',
653
654     editableModel: 'XM.Invoice'
655
656   });
657
658   /**
659     @class
660
661     @extends XM.Characteristic
662   */
663   XM.InvoiceCharacteristic = XM.CharacteristicAssignment.extend({
664     /** @scope XM.InvoiceCharacteristic.prototype */
665
666     recordType: 'XM.InvoiceCharacteristic',
667
668     which: 'isInvoices'
669
670   });
671
672   XM.InvoiceLineMixin = {
673
674     idAttribute: 'uuid',
675
676     sellingUnits: undefined,
677
678     readOnlyAttributes: [
679       "lineNumber",
680       "extendedPrice",
681       "taxTotal",
682       "customerPrice"
683     ],
684
685     //
686     // Core functions
687     //
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);
698
699       this.isMiscellaneousDidChange();
700     },
701
702     initialize: function (attributes, options) {
703       XM.Model.prototype.initialize.apply(this, arguments);
704       this.sellingUnits = new XM.UnitCollection();
705     },
706
707     //
708     // Model-specific functions
709     //
710
711     // XXX with the uncommented stuff back in, it's identical to the one in salesOrderBase
712     /**
713       Calculates and sets the extended price.
714
715       returns {Object} Receiver
716     */
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);
725       this.calculateTax();
726       this.recalculateParent();
727       return this;
728     },
729
730     /**
731       Calculate the price for this line item
732
733       @param{Boolean} force - force the net price to update, even if settings indicate not to.
734       @returns {Object} Receiver
735     */
736     calculatePrice: function (force) {
737       var settings = XT.session.settings,
738         K = this.getClass(),
739         that = this,
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,
750         listPrice;
751
752       // If no parent, don't bother
753       if (!parent) { return; }
754
755       // Make sure we have necessary values
756       if (canUpdate && customer && currency &&
757           item && quantity && quantityUnit &&
758           priceUnit && priceUnitRatio &&
759           parent.get(parent.documentDateKey)) {
760
761         _calculatePrice(this);
762       }
763       return this;
764     },
765
766     calculateTax: function () {
767       var parent = this.getParent(),
768         amount = this.get("extendedPrice"),
769         taxTypeId = this.getValue("taxType.id"),
770         recordType,
771         taxZoneId,
772         effective,
773         currency,
774         processTaxResponses,
775         that = this,
776         options = {},
777         params,
778         taxTotal = 0.00,
779         taxesTotal = [];
780
781       // If no parent, don't bother
782       if (!parent) { return; }
783
784       recordType = parent.recordType;
785       taxZoneId = parent.getValue("taxZone.id");
786       effective = parent.get(parent.documentDateKey);
787       currency = parent.get("currency");
788
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);
795               taxModel.set({
796                 taxType: that.get("taxType"),
797                 taxCode: resp.taxCode.code,
798                 amount: resp.tax
799               });
800               that.get("taxes").add(taxModel);
801               taxesTotal.push(resp.tax);
802               callback();
803             };
804             var taxModel = new XM.InvoiceLineTax();
805             taxModel.on("change:uuid", setTaxModel);
806             taxModel.initialize(null, {isNew: true});
807           };
808           var finish = function () {
809             that.recalculateParent(false);
810           };
811           that.get("taxes").reset(); // empty it out so we can populate it
812
813           async.map(responses, processTaxResponse, finish);
814           taxTotal = XT.math.add(taxesTotal, XT.COST_SCALE);
815           that.set("taxTotal", taxTotal);
816         };
817         options.success = processTaxResponses;
818         this.dispatch("XM.Tax", "taxDetail", params, options);
819       }
820     },
821
822     isMiscellaneousDidChange: function () {
823       var isMisc = this.get("isMiscellaneous");
824       if (isMisc) {
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);
830       } else {
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);
836       }
837     },
838
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
842       var that = this,
843         options = {},
844         parent = this.getParent(),
845         taxZone = parent && parent.get("taxZone"),
846         item = this.get("item"),
847         unitCost = item && item.get("standardCost");
848
849       // Reset values
850       this.unset("priceUnitRatio");
851       this.unset("taxType");
852       this.fetchSellingUnits();
853
854       if (!item) { return; }
855
856       // Fetch and update tax type
857       options.success = function (id) {
858         var taxType = XM.taxTypes.get(id);
859         if (taxType) {
860           that.set("taxType", taxType);
861         } else {
862           that.unset("taxType");
863         }
864       };
865
866       item.taxType(taxZone, options);
867
868       this.calculatePrice();
869     },
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"),
875        lineNumberArray,
876        maxLineNumber;
877
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");
882         }));
883         maxLineNumber = lineNumberArray.length > 0 ? Math.max.apply(null, lineNumberArray) : 0;
884         this.set("lineNumber", maxLineNumber + 1);
885       }
886     },
887
888     priceDidChange: function () {
889       this.calculateExtendedPrice();
890     }
891
892   };
893
894   /**
895     @class
896
897     @extends XM.Model
898   */
899   XM.InvoiceLine = XM.Model.extend(_.extend({}, XM.OrderLineMixin, XM.InvoiceLineMixin, {
900     /** @scope XM.InvoiceLine.prototype */
901
902     //
903     // Attributes
904     //
905     recordType: 'XM.InvoiceLine',
906
907     parentKey: "invoice",
908
909     altQuantityAttribute: "billed",
910
911     defaults: function () {
912       return {
913         site: XT.defaultSite(),
914         isMiscellaneous: false
915       };
916     }
917
918   }));
919
920   /**
921     @class
922
923     @extends XM.Model
924   */
925   XM.InvoiceLineTax = XM.Model.extend({
926     /** @scope XM.InvoiceLineTax.prototype */
927
928     recordType: 'XM.InvoiceLineTax',
929
930     idAttribute: 'uuid'
931
932   });
933
934   /**
935     @class
936
937     @extends XM.Model
938   */
939   XM.InvoiceContact = XM.Model.extend({
940     /** @scope XM.InvoiceContact.prototype */
941
942     recordType: 'XM.InvoiceContact',
943
944     isDocumentAssignment: true
945
946   });
947
948   /**
949     @class
950
951     @extends XM.Model
952   */
953   XM.InvoiceAccount = XM.Model.extend({
954     /** @scope XM.InvoiceAccount.prototype */
955
956     recordType: 'XM.InvoiceAccount',
957
958     isDocumentAssignment: true
959
960   });
961
962   /**
963     @class
964
965     @extends XM.Model
966   */
967   XM.InvoiceCustomer = XM.Model.extend({
968     /** @scope XM.InvoiceCustomer.prototype */
969
970     recordType: 'XM.InvoiceCustomer',
971
972     isDocumentAssignment: true
973
974   });
975
976   /**
977     @class
978
979     @extends XM.Model
980   */
981   XM.InvoiceFile = XM.Model.extend({
982     /** @scope XM.InvoiceFile.prototype */
983
984     recordType: 'XM.InvoiceFile',
985
986     isDocumentAssignment: true
987
988   });
989
990   /**
991     @class
992
993     @extends XM.Model
994   */
995   XM.InvoiceUrl = XM.Model.extend({
996     /** @scope XM.InvoiceUrl.prototype */
997
998     recordType: 'XM.InvoiceUrl',
999
1000     isDocumentAssignment: true
1001
1002   });
1003
1004   /**
1005     @class
1006
1007     @extends XM.Model
1008   */
1009   XM.InvoiceItem = XM.Model.extend({
1010     /** @scope XM.InvoiceItem.prototype */
1011
1012     recordType: 'XM.InvoiceItem',
1013
1014     isDocumentAssignment: true
1015
1016   });
1017
1018
1019
1020
1021   // ..........................................................
1022   // COLLECTIONS
1023   //
1024
1025   /**
1026     @class
1027
1028     @extends XM.Collection
1029   */
1030   XM.InvoiceListItemCollection = XM.Collection.extend({
1031     /** @scope XM.InvoiceListItemCollection.prototype */
1032
1033     model: XM.InvoiceListItem
1034
1035   });
1036
1037   /**
1038     @class
1039
1040     @extends XM.Collection
1041   */
1042   XM.InvoiceRelationCollection = XM.Collection.extend({
1043     /** @scope XM.InvoiceRelationCollection.prototype */
1044
1045     model: XM.InvoiceRelation
1046
1047   });
1048
1049
1050 }());