Skip to content

Commit

Permalink
First commit
Browse files Browse the repository at this point in the history
  • Loading branch information
jedateach committed Nov 13, 2012
0 parents commit cee64b2
Show file tree
Hide file tree
Showing 10 changed files with 414 additions and 0 deletions.
64 changes: 64 additions & 0 deletions README.md
@@ -0,0 +1,64 @@
# Tax framework for the SilverStripe Shop module

Tax is a fee that a merchant collects from the customer by law for the government. Some governments have
different tax rates for different kinds of products.

Different countries, and governing sub-regions have different laws for tax collection. Some sub-regions
charge taxes on top of their parent region tax, so the tax is 'compounding'.

Tax can be a complex thing to calculate. Different combinations of factors need to be taken into account:

* Locality / sub-locality of:
* Merchant
* Delivery
* Customer
* Type of product being sold
* Value of product
* Value of shipping

Because tax can be different per product, then we need to work out tax on a per-product basis. Total tax
can be displayed at the checkout. Tax could also be displayed on product pages.

### US Tax system

The US taxation system is one where state has it's own tax rate. At the time of writing New York state tax rate
is 8.875%. New Jersey is 7%. Connecticut is 6.35%, and so on. 50 states, 50 different tax rates.
If a merchant residing in New York is selling to a customer residing in New York, then the merchant collect
New York sales tax. If the merchant is selling to any other state, they do not need to collect any sales tax,
as it becomes the buyer's responsibility to report the purchase at the end of the year when preparing their
year-end taxes.

## Inclusive or exclusive

Sometimes you want to include tax in pricing, but still display the amount of tax being charged. Otherwise
tax should be displayed, and added on top of the amount being taxed.

## Compounding tax

Compounding tax is where a sub-locality (county) tax rate, is added to it's parent locality (state) rate.
You can manually add parent locality rates to sub-locality and store as a combined rate.

## Model

* TaxClass - type of tax, and whether it is the default. Each product, and shipping method has a tax class.
* TaxRate - region(s) to apply tax to, their corresponding rates, priority, name and whether it should be compounding
* TaxFrameworkModifier - applies tax to order

# Configuration options

* Base tax on billing address instead of shipping

## Provided data

Potentially the tax system could be pre-populated with data from a data source.

## Questions to be answered:

How is tax applied to products being sold to customers overseas?

## Client requirement:

Our end goal is to have the tax added, based on the state/region selected from a drop-down.
In the case of the U.S., there are 50 states, so somehow we'd need to manage the tax rate for each state.
In our current usage, we're only really interested in collecting sales tax if the product ships within the state of New York.
All other states would be 0 tax collected.
4 changes: 4 additions & 0 deletions _config.php
@@ -0,0 +1,4 @@
<?php

Object::add_extension("Product", "TaxedProductDecorator");
Object::add_extension("PopulateShopTask", "PopulateShopTaxClassesTask");
33 changes: 33 additions & 0 deletions code/TaxFrameworkModifier.php
@@ -0,0 +1,33 @@
<?php

class TaxFrameworkModifier extends OrderModifier{

public static $singular_name = "Tax";
function i18n_singular_name() {
return _t("TaxModifier.SINGULAR", self::$singular_name);
}
public static $plural_name = "Taxes";
function i18n_plural_name() {
return _t("TaxModifier.PLURAL", self::$plural_name);
}

/**
* TODO: reduce the number of database calls
*/
function value($incoming){
$order = $this->Order();
$value = 0;
if($order && $address = $order->ShippingAddress()){
$defaultclass = DataObject::get_one("TaxClass","\"Default\" = 1"); //get default rate
//sum taxe rates
foreach($order->Items() as $item){
$taxclass = ($item->Product()->TaxClass()->exists()) ? $item->Product()->TaxClass() : $defaultclass;
if($taxclass && $rate = $taxclass->getRate($address)){
$value += $item->Total() * $rate; //tax is total x rate
}
}
}
return $value;
}

}
12 changes: 12 additions & 0 deletions code/cms/TaxAdmin.php
@@ -0,0 +1,12 @@
<?php

class TaxAdmin extends ModelAdmin{

static $url_segment = "tax";
static $menu_title = "Tax";

static $managed_models = array(
"TaxClass"
);

}
83 changes: 83 additions & 0 deletions code/model/TaxClass.php
@@ -0,0 +1,83 @@
<?php

class TaxClass extends DataObject{

static $db = array(
'Name' => 'Varchar',
'Default' => 'Boolean'
);

static $has_many = array(
'TaxRates' => 'TaxRate'
);

static $summary_fields = array(
'Name',
'Default'
);

function getCMSFields(){
$fields = parent::getCMSFields();
$fieldList = array_merge(RegionRestriction::$field_labels,array(
'Rate' => 'Rate',
'Name' => 'Name',
//'Priority' => 'Priority',
//'Compounding' => 'Compounding'
));
$fieldTypes = array_merge(RegionRestriction::get_table_field_types(),array(
'Rate' => 'TextField',
'Name' => 'TextField',
//'Priority' => 'TextField',
//'Compounding' => 'CheckboxField'
));
$fields->fieldByName("Root")->removeByName('TaxRates'); //remove tax rates tab
if($this->isInDB()){
$tablefield = new TableField("TaxRates", "TaxRate", $fieldList, $fieldTypes);
$tablefield->setCustomSourceItems($this->TaxRates());
$fields->addFieldsToTab("Root.Main", array(
new LabelField("TaxRatesHelp", "Enter tax class rates for specific regions. Rates should be entered in decimal form, for example 0.05 = 5%."),
$tablefield
));
}
return $fields;
}

static function get_by_address(Address $address){
$where = RegionRestriction::address_filter($address);
$join = "INNER JOIN \"TaxRate\" ON \"TaxClass\".\"ID\" = \"TaxRate\".\"TaxClassID\" ";
return DataObject::get("TaxClass",$where, $sort = "", $join);
}

function getRate($address = null){
if(!$address){
$address = singleton('Address');
}
$where = array(
"\"TaxRate\".\"TaxClassID\" = {$this->ID}",
RegionRestriction::address_filter($address)
);
$sort = implode(', ',array(
RegionRestriction::wildcard_sort("PostalCode"),
RegionRestriction::wildcard_sort("City"),
RegionRestriction::wildcard_sort("State"),
RegionRestriction::wildcard_sort("Country"),
"\"Rate\" ASC"
));
if($rate = DataObject::get_one("TaxRate","(".implode(") AND (",$where).")",true,$sort)){
return $rate->Rate;
}
return 0;
}

function getTax($value){
return $value * $this->getRate();
}

function onBeforeWrite(){
parent::onBeforeWrite();
if($this->Default){
DB::query("UPDATE \"TaxClass\" SET \"Default\" = 0"); //clear any other default
}
}

}
31 changes: 31 additions & 0 deletions code/model/TaxRate.php
@@ -0,0 +1,31 @@
<?php
class TaxRate extends RegionRestriction{

static $db = array(
'Rate' => 'Percentage',
'Priority' => 'Int',
'Name' => 'Varchar', //eg: 'gst'
'Compounding' => 'Boolean'
);

static $has_one = array(
'TaxClass' => 'TaxClass'
);

static $defaults = array(
'Name' => 'TAX'
);

/**
* Prevent empty defaults
*/
function onBeforeWrite(){
foreach(self::$defaults as $field => $value){
if(empty($this->$field)){
$this->$field = $value;
}
}
parent::onBeforeWrite();
}

}
27 changes: 27 additions & 0 deletions code/model/TaxedProductDecorator.php
@@ -0,0 +1,27 @@
<?php

class TaxedProductDecorator extends DataObjectDecorator{

function extraStatics(){
return array(
'has_one' => array(
'TaxClass' => 'TaxClass'
)
);
}

function updateCMSFields($fields){
if($taxclasses = DataObject::get("TaxClass","","\"Name\" ASC")){
$fields->addFieldsToTab("Root.Content.Pricing", array(
new DropdownField("TaxClass","Tax Class",$taxclasses->map('ID','Name'))
));
}
}

function updateSellingPrice(&$price){
if($taxclass = $this->owner->TaxClass()){
$price += $taxclass->getTax($price); //TODO: specify address
}
}

}
27 changes: 27 additions & 0 deletions code/tasks/PopulateTaxClassesTask.php
@@ -0,0 +1,27 @@
<?php

class PopulateTaxClassesTask extends BuildTask{

protected $title = "Populate Tax Classes";
protected $description = "Creates tax classes";

function run($request = null){
if(!DataObject::get_one('TaxClass')){
$fixture = new YamlFixture('shop_taxframework/tests/fixtures/TaxClasses.yml');
$fixture->saveIntoDatabase();
DB::alteration_message('Created tax classes', 'created');
}else{
DB::alteration_message('Some tax classes already exist. None were created.');
}
}

}

class PopulateShopTaxClassesTask extends Extension{

function beforePopulate(){
$task = new PopulateTaxClassesTask();
$task->run();
}

}
33 changes: 33 additions & 0 deletions tests/TaxClassTest.php
@@ -0,0 +1,33 @@
<?php
class TaxClassTest extends SapphireTest{

static $fixture_file = array(
'shop/tests/fixtures/dummyproducts.yml',
'shop/tests/fixtures/Addresses.yml',
'shop_taxframework/tests/fixtures/TaxClasses.yml'
);

function testTaxRates(){
$producttaxclass = $this->objFromFixture("TaxClass", "products");
$groceriestaxclass = $this->objFromFixture("TaxClass", "groceries");

$us_arizona = $this->objFromFixture("Address", "aus85728");
$this->assertEquals($producttaxclass->getRate($us_arizona),0.066); //6.6%

//test other classes
$this->assertEquals($groceriestaxclass->getRate($us_arizona),0); //not charged tax

//test specificity
$anz1010 = $this->objFromFixture("Address", "anz1010");
$this->assertEquals($producttaxclass->getRate($anz1010),0.15); //15%

$wnz6012 = $this->objFromFixture("Address", "wnz6012");
$this->assertEquals($producttaxclass->getRate($wnz6012),0.50); //50%

}

/*function testTaxFrameworkModifier(){
}*/

}

0 comments on commit cee64b2

Please sign in to comment.