Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit cee64b2
Showing
10 changed files
with
414 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
<?php | ||
|
||
Object::add_extension("Product", "TaxedProductDecorator"); | ||
Object::add_extension("PopulateShopTask", "PopulateShopTaxClassesTask"); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?php | ||
|
||
class TaxAdmin extends ModelAdmin{ | ||
|
||
static $url_segment = "tax"; | ||
static $menu_title = "Tax"; | ||
|
||
static $managed_models = array( | ||
"TaxClass" | ||
); | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(){ | ||
}*/ | ||
|
||
} |
Oops, something went wrong.