diff --git a/README.txt b/README.txt
index ad57c67..66e1d36 100644
--- a/README.txt
+++ b/README.txt
@@ -43,12 +43,10 @@ Using real money, users can purchase points from your ecommerce store
 as well. Moreover, the points can be used as currency for ecommerce as well,
 as in a form of payment
 
-
 This module is useful in providing an incentive for users to participate
 in the site, and be more active. The module is easily extended through use of 
 the API (see below)
 
-
 Initially sponsored by: http://artalyst.com
 
 Installation
@@ -68,198 +66,33 @@ To enable this module do the following:
    Check the messages to make sure that you did not get any errors
    on database creation.
 
-2. Go to Admin -> Settings -> userpoints.
+2. Go to Admin -> Configuration -> People -> userpoints.
 
    Configure the options as per your requirements
 
-3. Go to Admin -> Access Control and enable viewing for the roles you want.
-
-For configuring with e-commerce, you have to have the ecommerce modules
-installed and configured.
-
-- User points can be used as a form of payment, with an admin defined
-  multiplier
-
-- Users gain points when purchasing items via e-commerce for every dollar
-  they spend.
-
-This is useful as a reward system.
-
-This also allows purchasing of points for real money. You have to setup
-a non-shippable product, and adjust the multiplier accordingly.
+3. Go to Admin -> People -> Permissions and configure as desired.
 
 API
 ---
-This modules provides an application programming interface (API), which is
-callable and actionable by other modules.
-
-The functions are:
-
-userpoints_userpointsapi()
-
-  Accepts an integer or an array. 
-  If the parameter is an integer it is assumed to be points 
-  for the currently logged in user (i.e. global $user; $user->uid) 
-
-  If the parameter is an array the array can contain one or more of the
-  following options. The only required parameters are 'points' or 'txn_id'
-  If a parameter is not set the site settings will used. Setting a parameter 
-  to NULL will cause the entry to be NULL, defaults are only used if the 
-  parameter is not set
-
-  Returns an array with a status (true/false) and a reason (string) if there
-  is an error. example
-  return array('status' => false, 'reason' => 'DB transaction failed');
-  
-  'uid'         => (int) User ID 
-  'points'      => (int) # of points to award the user 
-  'txn_id'      => (int) Transaction ID of a current points record. If
-                         present an UPDATE occurs
-  'moderate'    => (boolean) TRUE or FALSE. If NULL site settings are adhered to
-  'description' => (string) fulltext Description presented to the user
-  'expirydate'  => (timestamp) timestamp the date/time when the points will
-                               be expired (depends on cron)
-  'event'       => (string) varchar32 descriptive identifier administrative purposes
-  'reference'   => (string) varchar32 indexed/searchable field on the DB
-  'display'     => (boolean) Whether or not to display the "Points awarded"
-                             message. If null, fall back to USERPOINTS_DISPLAY_MESSAGE
-  'tid'         => (int) Taxonomy ID to place these points into; MUST BE in
-                         the userpoints Vocabulary!
-
-  Examples
-    //Add 5 points to the currently logged in user
-    userpoints_userpointsapi(5);  
-
-    //Also add 5 points to the currently logged in user
-    $params = array (
-      'uid' => $user->uid,
-      'points' => 5,
-    );
-    userpoints_userpointsapi($params); 
-
-  
-//---Hooks
-hook_userpoints($op, $params = array()) 
-
-  Use this hook to act upon certain operations. When other modules award
-  points to a user, your hook will be called, among others.
-
-  The arguments are:
-
-  $op: The operation to be acted upon.
-    'setting'
-      Pass a field set and fields that would be displayed in the userpoints
-      settings page. For example, this can be used for your program to ask
-      the admin to set a number of points for certain actions your module
-      performs. The function should return an array object conforming to
-      FormsAPI structure.
-
-    'points before'
-      Calls your module, and others, before the points are processed. You can
-      prevent points from being awarded by returning FALSE.
-
-    'points after'
-      Calls your module, and others, after points are processed. You can take
-      certain actions if you so wish. Return value is ignored.
 
-   The $params variable is the original $params array as sent to userpoints_userpointsapi
- 
-//---Other useful functions
+The API documentation can be found at api.worldempire.ch/api/userpoints.
 
-userpoints_get_current_points($uid = NULL, $tid = NULL);
-  Returns an integer of the sum of the user's point 
-  If a tid is passed in that category's sum is returned otherwise
-  the sites default category is used
-
-userpoints_get_max_points($uid = NULL, $tid = NULL);
-  Returns an integer of the sum of the user's max points achieved
-  If a tid is passed in that category's sum is returned otherwise
-  the sites default category is used
-
-userpoints_get_vid()
-  Returns an integer of the userpoints Vocabulary
-
-userpoints_get_default_tid()
-  Returns an integer for the userpoints default Taxonomy ID
-  Note: this is the default when submitting points so you 
-        DO NOT need to pass this into userpoints_userpointsapi
-
-userpoints_get_categories()
-  Returns an array of the possible categories including
-  the special "General" category (id=0). This is a keyed
-  array that works perfectly with FAPI. If you're creating a 
-  settings page wherein a user would select the category to 
-  place points into, this will give you exactly what you need.
-  See userpoints.module admin_settings function for an example.
-
-userpoints_get_default_expiry_date()
-  Returns a UNIX timestamp of the site's default expiration date.
-  If an expiration date (or interval) it will be returned otherwise NULL
-
-XML-RPC
--------
-
-Using the userpoints_services module, and the services modules, you 
-can allow external applications to query and update points on your
-site.
-
-Please refer to the services module documentation for further information.
-
-Userpoints provides the following XML-RPC calls:
-
-userpoints.get 
-
-  string api_key (required)
-    A valid API key.
-  int uid (required)
-    A valid Drupal User ID.
-  int tid (optional)
-    An optional Term ID for the category.
-
-Example:
-
-  $result = xmlrpc($server_url, 'userpoints.get', $key, $uid, $tid);
-  // $result is an array
-  // 'points' => 123
-
-userpoints.points
-
-  string api_key (required)
-    A valid API key.
-  int uid (required)
-    A valid Drupal User ID.
-  int points (required)
-    Number of points to add/subtract.
-  int tid (optional)
-    An optional Term ID for the category.
-  string event (optional)
-    An optional event ID for this transaction.
-  string description (optional)
-    An optional description of this transaction.
-
-Example:
-
-  $result = xmlrpc($server_url, 'userpoints.points', $key, $uid, $points, $tid, $event, $description);
-  // $result is an array
-  // 'status'
-  //   1 => Success
-  //   0 => Fail
-  // 'reason'
-  //   Textual reason for failure, if status is 0
 
 Bugs/Features/Patches:
 ----------------------
 If you want to report bugs, feature requests, or submit a patch, please do so
-at the project page on the Drupal web site.
+at the project page on drupal.org.
 http://drupal.org/project/userpoints
 
 Author
 ------
 Khalid Baheyeldin (http://baheyeldin.com/khalid and http://2bits.com)
 
+The Drupal 7 version of this module are maintained by Berdir
+(http://drupal.org/user/214652).
+
 If you use this module, find it useful, and want to send the author
 a thank you note, then use the Feedback/Contact page at the URL above.
 
 The author can also be contacted for paid customizations of this
 and other modules.
-
diff --git a/tests/userpoints_api.test b/tests/userpoints_api.test
index 26d02bc..c16366f 100644
--- a/tests/userpoints_api.test
+++ b/tests/userpoints_api.test
@@ -29,11 +29,11 @@ class UserpointsBaseTestCase extends DrupalWebTestCase {
    *   The most recent transaction id, assuming that this belongs to this
    *   transaction.
    */
-  function addPoints($points, $user, $total = NULL) {
+  function addPoints($points, $user, $total = NULL, $additional = array()) {
     $edit = array(
       'txn_user' => $user->name,
       'points' => $points,
-    );
+    ) + $additional;
     $this->drupalPost('admin/config/people/userpoints/add', $edit, t('Save'));
     if ($total !== NULL) {
       $categories = userpoints_get_categories();
@@ -168,146 +168,6 @@ class UserpointsAPITestCase extends UserpointsBaseTestCase {
     return (int) $query->execute()->fetchField();
   }
 
-  /**
-   * Call userpoints_userpointsapi() with just points.
-   */
-  function testBasicCall() {
-    global $user;
-
-    $points = (int) rand(1, 500);
-    $sumpoints = $points;
-
-    // NOTE: increment max points with all positive point changes, tests userpoints_get_max_points.
-    $maxpoints = $points;
-
-    // Test the a basic API call.
-    $return = userpoints_userpointsapi($points);
-    $this->assertTrue($return['status'] == TRUE, t('API responded with successful grant of points'));
-
-    // Check the database to ensure the point were properly saved.
-    $this->assertTrue($this->getTxnPoints($user->uid, $points) === $points, t('Successfully verified points in the txn table'));
-
-    // Check that the transaction table and the summary table match.
-    $this->assertTrue($this->getTxnPoints($user->uid, NULL, TRUE) === $this->getPoints($user->uid, NULL, TRUE), t('Sum of transactions match total points for user'));
-
-    // Add negative points to the initial value and check the values.
-    $points = -rand(1, 500);
-    $sumpoints = $sumpoints + $points;
-    userpoints_userpointsapi($points);
-
-    // Check the database to ensure the negative point value was properly saved.
-    $this->assertTrue($this->getTxnPoints($user->uid, $points) === $points, t('Successfully verified negative points in the txn table'));
-
-    // Now test to make sure the transaction and and caching table stay in sync.
-    // Also test userpoints_get_max_points and userpoints_get_current_points.
-    for ($i = 0; $i <= rand(1, 50); $i++) {
-      $points = rand(1, 500);
-      if (rand() & 1) {
-        $points = - $points;
-      }
-      $sumpoints = $sumpoints + $points;
-      if ($sumpoints > $maxpoints) {
-        $maxpoints = $sumpoints;
-      }
-      userpoints_userpointsapi($points);
-    }
-
-    // Check the summary table to make sure everything is still kosher.
-    $this->assertEqual($this->getTxnPoints($user->uid, NULL, TRUE), $this->getPoints($user->uid, NULL, TRUE));
-    $this->assertEqual($this->getPoints($user->uid, NULL, TRUE), $sumpoints);
-    $this->assertEqual($sumpoints, userpoints_get_current_points());
-    $this->assertEqual($maxpoints, userpoints_get_max_points());
-  }
-
-  /**
-   * Call the api functions with an array.
-   */
-  function testParamsArrayCall() {
-    $points = rand();
-
-    // Assert that the use of a params array with simply points in it works.
-    $params = array(
-      'points' => $points,
-      'uid' => $this->non_admin_user->uid,
-    );
-
-    $this->assertTrue(userpoints_userpointsapi($params) == TRUE,
-      t('API call using a params array responded with successful grant of points')
-    );
-
-    // Check the Database to make sure the points made it there.
-    $this->assertTrue($this->getPoints($this->non_admin_user->uid, NULL, TRUE) == $points, t('Successfully verified points in the txn table'));
-
-    // Test to ensure that calling it with no points returns FALSE indicating an error.
-    $params = array(
-      'points' => '',
-      'uid' => $this->non_admin_user->uid,
-    );
-
-    $return = userpoints_userpointsapi($params);
-    $this->assertFalse($return['status'],
-            t('API successfully prevented null points from being added'));
-
-    $params = array(
-      'points' => 'abcd',
-      'uid' => $this->non_admin_user->uid,
-    );
-    $return = userpoints_userpointsapi($params);
-    $this->assertFalse($return['status'], t('API successfully prevented non-numeric points from being added'));
-
-    // Award points to admin user and test to ensure they were awarded to the correct user.
-    $params = array(
-      'points' => $points,
-      'uid' => $this->admin_user->uid,
-    );
-
-    $this->assertTrue(userpoints_userpointsapi($params) == TRUE, t('Successfully granted points to admin user'));
-
-    // Check the Database to make sure the points made it there.
-    $this->assertTrue($this->getPoints($this->non_admin_user->uid, NULL, TRUE) == $points, t('Successfully verified points in the txn table'));
-
-    // Attempt to award points to a non-existent user.
-    $sql = "SELECT MAX(uid) FROM {users}";
-    $nonuid = db_query($sql)->fetchField() + 1;
-    $params = array(
-      'points' => $points,
-      'uid' => $nonuid,
-    );
-
-    $ret = userpoints_userpointsapi($params);
-    $this->assertFalse($ret['status'], t('Successfully blocked points given to a non-existent user'));
-
-    /**
-     * @todo: This is not actually implemented, just passed the tests because it
-     *        has set the 'uid' key instead of 'tid'.
-    // Attempt to award points to a non-existent term.
-    $maxtid = "SELECT MAX(tid) from {taxonomy_term_data}";
-    $nontid = db_query($sql)->fetchField() + 1;
-    $params = array(
-      'points' => $points,
-      'tid' => $nontid,
-    );
-
-    $ret = userpoints_userpointsapi($params);
-    $this->assertFalse($ret['status'], t('Successfully blocked points given to a non-existent tid'));
-    */
-    // Test various aspects of the API to ensure the DB is being updated successfully.
-    $points = rand(1, 500);
-    $description = $this->randomName();
-    $operation = $this->randomName();
-    $params = array(
-      'points' => $points,
-      'description' => $description,
-      'operation' => $operation,
-    );
-
-    $ret = userpoints_userpointsapi($params);
-    $sql = "SELECT description, operation, reference FROM {userpoints_txn} WHERE description = :description AND operation = :operation";
-    $db_point_rec = db_query($sql, array(':description' => $description, ':operation' => $operation))->fetchAssoc();
-    $this->assertTrue($db_point_rec['description'] == $description, t('Point description successfully verified in DB'));
-    $this->assertTrue($db_point_rec['operation'] == $operation, t('Point event successfully verified in DB'));
-  }
-
   function testExpiration() {
     $points = rand(1, 100);
     $sum_points = 0;
@@ -322,30 +182,27 @@ class UserpointsAPITestCase extends UserpointsBaseTestCase {
 
     $bad_time = 'test string';
     // First lets check to make sure it is blocking bad times.
-    $params = array(
-        'uid' => $this->non_admin_user->uid,
-        'points' => $points,
-        'expirydate' => $bad_time,
-    );
-    $return = userpoints_userpointsapi($params);
-    $this->assertFalse($return['status'], t(print_r($return, TRUE) . "API succesfully blocked an entry with a string as the expiry date"));
-
-
-    foreach ($times as $time) {
-      $params = array(
-          'uid' => $this->non_admin_user->uid,
-          'points' => $points,
-          'expirydate' => $time['time'],
-      );
-      $return = userpoints_userpointsapi($params);
-      $this->assertTrue($return['status'], t($time['string'] . " API responded with a successful grant of points"));
+    try {
+      userpoints_grant_points('bad_time', $points, $this->non_admin_user->uid)
+        ->setExpiryDate($bad_time)
+        ->save();
+
+      $this->fail(t('API succesfully blocked an entry with a string as the expiry date'));
+    } catch (UserpointsInvalidArgumentException $e) {
+      $this->pass(t('API succesfully blocked an entry with a string as the expiry date'));
+    }
+
+    foreach ($times as $key => $time) {
+      $transaction = userpoints_grant_points($key, $points, $this->non_admin_user->uid)
+        ->setExpiryDate($time['time'])
+        ->save();
+      $this->assertTrue((bool)$transaction->getTxnId(), t($time['string'] . " API responded with a successful grant of points"));
       // Check the database to ensure the points were properly saved.
       $sql = "SELECT points FROM {userpoints_txn} WHERE uid = :uid AND points = :points AND expirydate = :date";
       $db_points = (int) db_query($sql, array(':uid' => $this->non_admin_user->uid, ':points' => $points, ':date' => (int) $time['time']))->fetchField();
       $this->assertEqual($db_points, $points, t($time['string'] . "Successfully verified points in the txn table."));
-      if ($db_points == $points) {
-        $sum_points = $sum_points + $points;
-      }
+
+      $sum_points += $points;
 
       // Check update point to 'userpoints' table.
       $this->assertEqual($this->getPoints($this->non_admin_user->uid), $sum_points, t($time['string'] . "Successfully verified that the summary table was updated"));
@@ -363,23 +220,16 @@ class UserpointsAPITestCase extends UserpointsBaseTestCase {
     // Add two different points in, one to post immediately another to expire in the future.
     $keep_points = rand(1, 100);
     $expire_points = rand(1, 100);
-    $params = array(
-      'uid' => $this->non_admin_user->uid,
-      'points' => $expire_points,
-      'expirydate' => REQUEST_TIME - 1000,
-      'operation' => 'must_expire',
-    );
-    $return = userpoints_userpointsapi($params);
-    $this->assertTrue($return['status'], t("API succesfully added points for expiration"));
-
-    $params = array(
-      'uid' => $this->non_admin_user->uid,
-      'points' => $keep_points,
-      'expirydate' => REQUEST_TIME + 10000,
-      'operation' => 'must_not_expire',
-    );
-    userpoints_userpointsapi($params);
-    $this->assertTrue($return['status'], t("API succesfully added points for expiration"));
+
+    $transaction = userpoints_grant_points('must_expire', $expire_points, $this->non_admin_user->uid)
+        ->setExpiryDate(REQUEST_TIME - 100)
+        ->save();
+    $this->assertTrue((bool)$transaction->getTxnId(), t("API succesfully added points for expiration"));
+
+    $transaction = userpoints_grant_points('must_not_expire', $keep_points, $this->non_admin_user->uid)
+        ->setExpiryDate(REQUEST_TIME + 10000)
+        ->save();
+    $this->assertTrue((bool)$transaction->getTxnId(), t("API succesfully added points for expiration"));
 
     // Load timestamp of the first transaction.
     $time_stamp = db_query('SELECT time_stamp FROM {userpoints_txn} WHERE operation = :op', array(':op' => 'must_expire'))->fetchField();
@@ -497,11 +347,7 @@ class UserpointsAPITestCase extends UserpointsBaseTestCase {
     $this->assertTrue($content == FALSE, t("Successful navigated to the page modify points"));
 
     // check modify points.
-    $edit = array(
-        'txn_user' => $this->admin_user->name,
-        'points' => $points,
-    );
-    $this->drupalPost('admin/config/people/userpoints/add', $edit, 'Save');
+    $this->addPoints($points, $this->admin_user);
 
     // Check database.
     $this->assertEqual($this->getTxnPoints($this->admin_user->uid, $points), $points, t("Successful verified that points were added into database."));
@@ -517,192 +363,6 @@ class UserpointsAPITestCase extends UserpointsBaseTestCase {
     $this->drupalGet('admin/config/people/userpoints');
     $this->assertResponse(403, t("Successful verified that a user without admin userpoints permissions can not access the admin interface."));
   }
-
-  function testModeration() {
-    $points = rand(1, 100);
-
-    // condition1 moderate=TRUE.
-    $params = array(
-        'uid' => $this->non_admin_user->uid,
-        'points' => $points,
-        'moderate' => TRUE,
-    );
-    // add points to user.
-    $return = userpoints_userpointsapi($params);
-    $this->assertTrue($return['status'] == TRUE, t("1. (moderate=TRUE) API responded with successful grant of points"));
-
-    // Check the database to ensure the point were properly saved.
-    $this->assertTrue($this->getTxnPoints($this->non_admin_user->uid, $points) == $points, t("1. (moderate=TRUE) Successfully verified points in the txn table and waiting moderation."));
-
-    // Check do not update point to 'userpoints' table.
-    $this->assertTrue($this->getPoints($this->non_admin_user->uid) == 0, t("1. (moderate=TRUE) Successfully verified that points were added and the summary table was not updated."));
-
-    /*
-      //DISABLED because it should be checking if it adhered to the sites default moderation status
-      $params = array (
-      'uid' => $this->non_admin_user->uid,
-      'points' => $points,
-      'moderate' => NULL,
-      );
-      $return = userpoints_userpointsapi($params);
-      $this->assertTrue($return['status'] == TRUE , t("6. (moderate=NULL) API responded with successful grant of points"));
-
-      $sql = "SELECT points FROM {userpoints_txn} WHERE uid = %d AND points = %d AND status = 1";
-      $db_points = (int) db_result(db_query($sql, $this->non_admin_user->uid, $points));
-      $this->assertTrue($db_points == $points,t("6. (moderate=NULL) Successfully verified points in the txn table and waiting moder.") );
-
-      $sql1 = "SELECT points FROM {userpoints} WHERE uid=%d";
-      $db_points = (int) db_result(db_query($sql1, $this->non_admin_user->uid));
-      $this->assertTrue($db_points == 0,t("6. (moderate=NULL) Successfully, Points added and does not modify summary table.") );
-
-    */
-
-    // condition7 moderate=FALSE.
-    $params = array(
-        'uid' => $this->non_admin_user->uid,
-        'points' => $points,
-        'moderate' => FALSE,
-    );
-    // add points to user.
-    $return = userpoints_userpointsapi($params);
-    $this->assertTrue($return['status'] == TRUE, t("7. (moderate=FALSE) API responded with successful grant of points"));
-
-    // Check the database to ensure the point were properly saved.
-    $sql = "SELECT points FROM {userpoints_txn} WHERE uid = :uid AND points = :points AND status = 0";
-    $db_points = (int) db_query($sql, array(':uid' => $this->non_admin_user->uid, ':points' => $points))->fetchField();
-    $this->assertTrue($db_points == $points, t("7. (moderate=FALSE) Successfully verified points in the txn table and NOT waiting moderation."));
-  }
-
-  /**
-   * Tests the userpoints_get_categories() function.
-   */
-  function testGetCategories() {
-    $cats = userpoints_get_categories();
-    $this->assertTrue(is_array($cats), 'Successfully verified userpoints_get_categories() returned an array');
-  }
-
-
-  /**
-   * Test that editing points correctly updates the current and max points.
-   */
-  function testEditingTransactions() {
-    // First, add some points to two different categories.
-    $uid = $this->non_admin_user->uid;
-    $params = array(
-      'points' => 100,
-      'tid' => 0,
-      'uid' => $uid,
-    );
-    userpoints_userpointsapi($params);
-
-    $params = array(
-      'points' => 50,
-      'tid' => 1,
-      'uid' => $uid,
-    );
-    userpoints_userpointsapi($params);
-
-    // Add a third transaction that can be edited.
-    $params = array(
-      'points' => 5,
-      'tid' => 0,
-      'uid' => $uid,
-    );
-    $return = userpoints_userpointsapi($params);
-    $txn_id = $return['transaction']['txn_id'];
-
-    // Verify points up to this point.
-    $this->verifyPoints($uid, 105, 105, 0);
-    $this->verifyPoints($uid, 50, 50, 1);
-
-    // Now, edit the transaction. Mix any combination of point, category and
-    // status changes. After the change, verify current and max points.
-
-    // Points change.
-    $params = array(
-      'txn_id' => $txn_id,
-      'points' => -5,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 95, 105, 0);
-
-    // Change status to pending.
-    $params = array(
-      'txn_id' => $txn_id,
-      'status' => USERPOINTS_TXN_STATUS_PENDING,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 100, 105, 0);
-
-    // Change status back to approved.
-    $params = array(
-      'txn_id' => $txn_id,
-      'status' => USERPOINTS_TXN_STATUS_APPROVED,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 95, 105, 0);
-
-    // Change category.
-    $params = array(
-      'txn_id' => $txn_id,
-      'tid' => 1,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 100, 105, 0);
-    $this->verifyPoints($uid, 45, 50, 1);
-
-    // Change points and status.
-    $params = array(
-      'txn_id' => $txn_id,
-      'points' => 3,
-      'status' => USERPOINTS_TXN_STATUS_PENDING,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 100, 105, 0);
-    $this->verifyPoints($uid, 50, 50, 1);
-
-    // Change status back to approved.
-    $params = array(
-      'txn_id' => $txn_id,
-      'status' => USERPOINTS_TXN_STATUS_APPROVED,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 53, 53, 1);
-
-    // Change points and category.
-    $params = array(
-      'txn_id' => $txn_id,
-      'points' => 9,
-      'tid' => 0,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 109, 109, 0);
-    $this->verifyPoints($uid, 50, 53, 1);
-
-    // Change points and status and category.
-    $params = array(
-      'txn_id' => $txn_id,
-      'points' => 10,
-      'tid' => 1,
-      'status' => USERPOINTS_TXN_STATUS_DECLINED,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 100, 109, 0);
-    $this->verifyPoints($uid, 50, 53, 1);
-
-    // Change points and status back to approved.
-    $params = array(
-      'txn_id' => $txn_id,
-      'points' => 4,
-      'status' => USERPOINTS_TXN_STATUS_APPROVED,
-    );
-    userpoints_userpointsapi($params);
-    $this->verifyPoints($uid, 100, 109, 0);
-    $this->verifyPoints($uid, 54, 54, 1);
-
-    $this->verifyPoints($uid, 154, 159, 'all');
-  }
-
 }
 
 /**
@@ -744,12 +404,13 @@ class UserpointsAdminTestCase extends UserpointsBaseTestCase {
     $category = $categories[$tid];
 
     // Grant some points with admin user.
-    $txn_id = $this->addPoints(10, $user, 10);
+    $txn_id = $this->addPoints(10, $user, NULL, array('moderate' => 1));
+    $this->assertText(t('@user just earned @points points, pending administrator approval.', array('@user' => $user->name, '@points' => 10)));
 
-    // Go to the listing page, verify that the user is shown.
+    // Go to the listing page, verify that the user is not shown yet, as the
+    // points are still pending.
     $row = $this->xpath('//table/tbody/tr');
-    //$this->assertEqual(strip_tags((string)$row[0]->td[0]), t('@name (details)', array('@name' => $user->name)), t('User name with details link displayed.'));
-    $this->assertEqual((string)$row[0]->td[1], 10, t('Points correctly displayed.'));
+    $this->assertTrue(empty($row));
 
     // Go to the transaction listing page, verify that the transaction is shown.
     $this->clickLink(t('Transactions'));
@@ -759,12 +420,12 @@ class UserpointsAdminTestCase extends UserpointsBaseTestCase {
     $this->assertEqual((string)$row[0]->td[1], 10, t('Points correctly displayed.'));
     $this->assertEqual((string)$row[0]->td[2], format_date($transaction->time_stamp, 'small'), t('Date correctly displayed.'));
     $this->assertEqual((string)$row[0]->td[3], 'admin', t('Reason correctly displayed.'));
-    $this->assertEqual((string)$row[0]->td[4], t('Approved'), t('Status correctly displayed.'));
+    $this->assertEqual((string)$row[0]->td[4], t('Pending'), t('Status correctly displayed.'));
 
     $this->clickLink(t('edit'));
 
     // Verify default values.
-    $this->assertFieldByName('points', 10);
+    $this->assertFieldByName('points', 10, t('Points default value is correct.'));
     $value = $this->xpath("//input[@name=:name and @disabled=:disabled]/@value", array(':name' => 'txn_user', ':disabled' => 'disabled'));
     $this->assertEqual($value[0]['value'], $user->name, t('User field has the correct value and is disabled.'));
     $this->assertFieldByName('approver', $this->admin_user->name);
@@ -774,6 +435,7 @@ class UserpointsAdminTestCase extends UserpointsBaseTestCase {
       'operation' => $this->randomName(),
       'description' => $this->randomName(),
       'reference' => $this->randomName(),
+      'status' => UserpointsTransaction::STATUS_APPROVED,
     );
     $this->drupalPost(NULL, $edit, t('Save'));
 
@@ -800,3 +462,97 @@ class UserpointsAdminTestCase extends UserpointsBaseTestCase {
 
   }
 }
+
+
+/**
+ * API Tests.
+ */
+class UserpointsGrantPointsTestCase extends UserpointsBaseTestCase {
+
+  private $admin_user;
+  private $non_admin_user;
+
+  /**
+   * Implements getInfo().
+   */
+  function getInfo() {
+    return array(
+      'name' => t('Userpoints grant points'),
+      'description' => t('Tests the core API for proper inserts & updates to the database tables.'),
+      'group' => t('Userpoints'),
+    );
+  }
+
+  /**
+   * Install userpoints module and create users.
+   */
+  function setUp() {
+    parent::setUp('userpoints');
+
+    // Create an administrator account.
+    $this->admin_user = $this->drupalCreateUser(array('administer userpoints'));
+
+    // Create a standard Drupal account and log in as that person.
+    $this->non_admin_user = $this->drupalCreateUser();
+    $this->drupalLogin($this->non_admin_user);
+  }
+
+  /**
+   * Test basic usage of the API to create and update transactions.
+   */
+  function testGrantPoints() {
+    // Most basic usage, with automated saving.
+    userpoints_grant_points('test', 10, $this->non_admin_user->uid);
+    $this->verifyPoints($this->non_admin_user->uid, 10, 10);
+
+    // Negative points, use of save().
+    userpoints_grant_points('test', -5, $this->non_admin_user->uid)
+      ->save();
+    $this->verifyPoints($this->non_admin_user->uid, 5, 10);
+
+    // Verify that pending points are not added to the total.
+    $transaction = userpoints_grant_points('test', 7, $this->non_admin_user->uid)
+      ->pending()
+      ->save();
+    $this->verifyPoints($this->non_admin_user->uid, 5, 10);
+
+    // Make sure that loaded transactions can be updated, and after marked as
+    // approved, the points are correct.
+    $transaction = userpoints_transaction_load($transaction->getTxnId());
+    $transaction
+      ->approve()
+      ->save();
+    $this->verifyPoints($this->non_admin_user->uid, 12, 12);
+
+    try {
+      // Approved transaction must not be changed.
+      $transaction
+        ->setPoints(123)
+        ->save();
+      $this->fail(t('Changing a approved transaction was not denied.'));
+    } catch (UserpointsChangeException $e) {
+      $this->pass(t('Changing a approved transaction was denied.'));
+    }
+
+    $transaction = userpoints_grant_points('test', 19, $this->non_admin_user->uid)
+      ->pending()
+      ->save();
+    $this->verifyPoints($this->non_admin_user->uid, 12, 12);
+
+    // Transactions can be declined.
+    $transaction
+      ->decline()
+      ->save();
+    $this->verifyPoints($this->non_admin_user->uid, 12, 12);
+
+    try {
+      // Declined transaction must not be changed.
+      $transaction
+        ->setPoints(123)
+        ->save();
+      $this->fail(t('Changing a declined transaction was not denied.'));
+    } catch (UserpointsChangeException $e) {
+      $this->pass(t('Changing a declined transaction was denied.'));
+    }
+  }
+}
\ No newline at end of file
diff --git a/userpoints.admin.inc b/userpoints.admin.inc
index 76fe643..17d1d79 100644
--- a/userpoints.admin.inc
+++ b/userpoints.admin.inc
@@ -7,13 +7,10 @@
 
 function userpoints_confirm_approve_submit($form, &$form_state) {
   global $user;
-
-  $params = array(
-    'txn_id' => $form_state['values']['txn_id'],
-    'approver_uid' => $user->uid,
-    'status' => $form_state['values']['operation'],
-  );
-  userpoints_userpointsapi($params);
+  userpoints_transaction_load($form_state['values']['txn_id'])
+    ->setApproverUid($user->uid)
+    ->setStatus($form_state['values']['operation'])
+    ->save();
 
   $form_state['redirect'] = 'admin/config/people/userpoints/moderate';
 }
@@ -26,6 +23,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
   drupal_add_css(drupal_get_path('module', 'userpoints') . '/userpoints.css');
 
   $timestamp = format_date(REQUEST_TIME, 'custom', 'Y-m-d H:i O');
+
   if ($mode == 'edit') {
     drupal_set_title(t('Edit !points transaction', userpoints_translation()));
     $timestamp = format_date($txn->time_stamp, 'custom', 'Y-m-d H:i:s O');
@@ -41,7 +39,16 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
     if ($txn) {
       $txn_user = user_load($txn);
     }
+    $txn = NULL;
+  }
+
+  // If this transaction is read only, disable all fields.
+  $disable = $txn && $txn->isReadOnly();
+
+  if ($disable) {
+    drupal_set_message(t('This transaction is read only and can not be changed.'), 'warning');
   }
+
   $form['txn_user'] = array(
       '#type' => 'textfield',
       '#title' => t('User Name'),
@@ -52,6 +59,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#description' => t('The name of the user who should gain or lose !points.', userpoints_translation()),
       '#required' => TRUE,
       '#weight' => -20,
+      // The user field can never be changed.
       '#disabled' => $mode == 'edit',
   );
 
@@ -60,10 +68,11 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#title' => t('Points'),
       '#size' => 10,
       '#maxlength' => 10,
-      '#default_value' => isset($txn->points) ? $txn->points : 0,
+      '#default_value' => $txn ? $txn->points : 0,
       '#description' => t('The number of !points to add or subtract.  For example, enter %positive to add !points or %negative to deduct !points.', array('%positive' => 25, '%negative' => -25) + userpoints_translation()),
       '#required' => TRUE,
       '#weight' => -15,
+      '#disabled' => $disable,
   );
 
   if (module_exists('taxonomy')) {
@@ -71,12 +80,13 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
     $form['tid'] = array(
       '#type' => 'select',
       '#title' => t('Category'),
-      '#default_value' => isset($txn->tid) ? $txn->tid : userpoints_get_default_tid(),
+      '#default_value' => $txn ? $txn->tid : userpoints_get_default_tid(),
       '#options' => $options,
       '#description' => t('The !points category that should apply to this transaction.', userpoints_translation()),
       '#weight' => 0,
       // Only show the category if there are actually categories to choose from.
       '#access' => count($options) > 1,
+      '#disabled' => $disable,
     );
   }
 
@@ -98,6 +108,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#default_value' => variable_get(USERPOINTS_POINTS_MODERATION, 0),
       '#access' => userpoints_admin_access('moderate'),
       '#weight' => -10,
+      '#disabled' => $disable,
     );
   }
   else {
@@ -109,6 +120,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#default_value' => $txn->status,
       '#access' => userpoints_admin_access('moderate'),
       '#weight' => -10,
+      '#disabled' => $disable,
     );
   }
 
@@ -122,6 +134,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
     '#weight' => -5,
     // Do not show this if it is not allowed to change the timestamp anyway.
     '#access' => !variable_get(USERPOINTS_TRANSACTION_TIMESTAMP, 1),
+    '#disabled' => $disable,
   );
 
   $expirydate = 0;
@@ -145,6 +158,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#maxlength' => 30,
       '#description' => t('The date and time that the !points should expire. Use this format: YYYY-MM-DD HH:MM +ZZZZ. Leave this field blank if the !points should never expire.', userpoints_translation()),
       '#weight' => 25,
+      '#disabled' => $disable,
   );
 
   $form['reason'] = array(
@@ -156,22 +170,24 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
   $form['reason']['operation'] = array(
       '#type' => 'textfield',
       '#title' => t('Operation'),
-      '#default_value' => isset($txn->operation) ? $txn->operation : t('admin'),
+      '#default_value' => isset($txn) ? $txn->operation : t('admin'),
       '#maxlength' => 48,
       '#description' => t('The operation type for this transaction (default is %admin). Any value is valid but using a defined operation will cause an auto-generated description (specific to the chosen operation) to be included. This description can be translated into multiple languages.', array('%admin' => t('admin'))),
       '#weight' => 5,
       '#required' => FALSE,
       '#autocomplete_path' => 'userpoints/operation-autocomplete',
+      '#disabled' => $disable,
   );
 
   $form['reason']['description'] = array(
       '#type' => 'textarea',
       '#title' => t('Description'),
-      '#default_value' => isset($txn->description) ? $txn->description : '',
+      '#default_value' => isset($txn) ? $txn->description : '',
       '#rows' => 7,
       '#cols' => 40,
       '#description' => t('Enter an optional description for this transaction, such as the reason !points were added or subtracted.', userpoints_translation()),
       '#weight' => 10,
+      '#disabled' => $disable,
   );
 
   $form['reference'] = array(
@@ -189,7 +205,8 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
     '#title' => t('Linked entity'),
     '#weight' => 0,
     '#options' => $options,
-    '#default_value' => isset($txn->entity_type) ? $txn->entity_type : '',
+    '#default_value' => isset($txn) ? $txn->entity_type : '',
+    '#disabled' => $disable,
   );
 
   $form['reference']['entity_id'] = array(
@@ -199,21 +216,23 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
     '#weight' => 5,
     '#size' => 5,
     '#maxlength' => 20,
-    '#default_value' => isset($txn->entity_id) ? $txn->entity_id : '',
+    '#default_value' => isset($txn) ? $txn->entity_id : '',
     '#description' => t('Choose the entity type and ID to be referenced. A link to the entity will be shown.'),
+      '#disabled' => $disable,
   );
 
   $form['reference']['reference'] = array(
       '#type' => 'textfield',
       '#title' => t('Internal reference'),
-      '#default_value' => isset($txn->reference) ? $txn->reference : '',
+      '#default_value' => isset($txn) ? $txn->reference : '',
       '#size' => 30,
       '#maxlength' => 128,
       '#description' => t('Enter an optional reference code for this transaction. This is for internal tracking and is not shown to the end user.', userpoints_translation()),
       '#weight' => 10,
+      '#disabled' => $disable,
   );
 
-  $approved_by = !empty($txn->approver_uid) ? user_load($txn->approver_uid) : NULL;
+  $approved_by = $txn ? $txn->getApprover() : NULL;
   if ($approved_by) {
     $form['status']['approver'] = array(
       '#type' => 'textfield',
@@ -223,6 +242,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#maxlength' => 30,
       '#description' => t('The user who gave the transaction its current status.'),
       '#weight' => 30,
+      '#disabled' => $disable,
     );
   }
 
@@ -235,6 +255,7 @@ function userpoints_admin_txn($form, &$form_state, $mode, $txn = NULL) {
       '#type' => 'submit',
       '#value' => t('Save'),
       '#weight' => 50,
+      '#disabled' => $disable,
   );
   return $form;
 }
@@ -311,63 +332,57 @@ function userpoints_admin_txn_validate($form, &$form_state) {
  */
 function userpoints_admin_txn_submit($form, &$form_state) {
   global $user;
+
+  // Check if this is a new transaction or if we are editing an existing one.
   if ($form_state['values']['mode'] == 'add') {
-    $params = array(
-      'points' => $form_state['values']['points'],
-      'uid' => $form_state['values']['txn_user']->uid,
-      'operation' => $form_state['values']['operation'],
-      'description' => $form_state['values']['description'],
-      'reference' => $form_state['values']['reference'],
-      'tid' => $form_state['values']['tid'],
-      'time_stamp' => strtotime($form_state['values']['time_stamp']),
-      'moderate' => (bool)$form_state['values']['moderate'],
-      'approver_uid' => $user->uid,
-    );
-    if ($form_state['values']['expirydate']) {
-      // Check for the existence of an expirydate.
-      $params['expirydate'] = strtotime($form_state['values']['expirydate']);
+
+    // Create a new transaction object.
+    $transaction = userpoints_grant_points($form_state['values']['operation'], $form_state['values']['points'], $form_state['values']['txn_user']->uid)
+      ->setTimestamp($form_state['values']['time_stamp']);
+
+    // If the transaction should be moderated, set it to pending.
+    if ($form_state['values']['moderate']) {
+      $transaction->pending();
     }
-    if (!empty($form_state['values']['entity_id']) && !empty($form_state['values']['entity_type'])) {
-      $params['entity_type'] = $form_state['values']['entity_type'];
-      $params['entity_id'] = (int)$form_state['values']['entity_id'];
+
+    // New transactions are always "approved" by the current user.
+    $transaction->setApproverUid($user->uid);
+
+    // Check for the existence of an expirydate.
+    if ($form_state['values']['expirydate']) {
+      $transaction->setExpiryDate(strtotime($form_state['values']['expirydate']));
     }
   }
   else {
-    $expirydate = 0;
-    if (!empty($form_state['values']['expirydate'])) {
-      $expirydate = strtotime($form_state['values']['expirydate']);
-    }
 
-    // If status changed, the current user is the new approver, when not
-    // changed, then the current approver is kept.
-    if ($form_state['values']['txn']->status == $form_state['values']['status']) {
-      $approver_uid = $form_state['values']['txn']->approver_uid;
-    }
-    else {
-      $approver_uid = $user->uid;
+    // Updating an existing transaction, load and update values.
+    $transaction = userpoints_transaction_load($form_state['values']['txn']->txn_id)
+      ->setPoints($form_state['values']['points'])
+      ->setStatus($form_state['values']['status'])
+      // We display a custom message instead of the default.
+      ->setMessage(t('Changes to the !points transaction have been saved.', userpoints_translation()));
+
+    // Allow to remove expiration date, first set it to 0 and only set it back
+    // if explicitly set.
+    $transaction->setExpiryDate(0);
+    if (!empty($form_state['values']['expirydate'])) {
+      $transaction->setExpiryDate(strtotime($form_state['values']['expirydate']));
     }
 
-    $params = array(
-      'uid' => $form_state['values']['txn']->uid,
-      'approver_uid' => $approver_uid,
-      'points' => $form_state['values']['points'],
-      'tid' => $form_state['values']['tid'],
-      'time_stamp' => strtotime($form_state['values']['time_stamp']),
-      'operation' => $form_state['values']['operation'],
-      'description' => $form_state['values']['description'],
-      'reference' => $form_state['values']['reference'],
-      'status' => $form_state['values']['status'],
-      'expirydate' => $expirydate,
-      'txn_id' => $form_state['values']['txn']->txn_id,
-      'display' => FALSE,
-    );
-    if (!empty($form_state['values']['entity_id']) && !empty($form_state['values']['entity_type'])) {
-      $params['entity_type'] = $form_state['values']['entity_type'];
-      $params['entity_id'] = (int)$form_state['values']['entity_id'];
+    // If status changed, the current user is the new approver.
+    if ($form_state['values']['txn']->status != $form_state['values']['status']) {
+      $transaction->setApproverUid($user->uid);
     }
-    drupal_set_message(t('Changes to the !points transaction have been saved.', userpoints_translation()));
   }
-  userpoints_userpointsapi($params);
+
+  // Set common properties and save the transaction.
+  $transaction
+    ->setTid($form_state['values']['tid'])
+    ->setOperation($form_state['values']['operation'])
+    ->setReference($form_state['values']['reference'])
+    ->setDescription($form_state['values']['description'])
+    ->setEntity($form_state['values']['entity_type'], $form_state['values']['entity_id'])
+    ->save();
 
   $form_state['redirect'] = 'admin/config/people/userpoints';
 }
@@ -462,7 +477,7 @@ function userpoints_admin_transactions($form, &$form_state, $moderate) {
     ->limit(variable_get(USERPOINTS_REPORT_USERCOUNT, 30));
 
   if ($moderate) {
-    $query->condition('p.status', USERPOINTS_TXN_STATUS_PENDING);
+    $query->condition('p.status', UserpointsTransaction::STATUS_PENDING);
   }
 
   if (module_exists('taxonomy')) {
@@ -490,8 +505,10 @@ function userpoints_admin_transactions($form, &$form_state, $moderate) {
   }
 
   $rows = array();
-  foreach ($query->execute() as $transaction) {
-    $rows[] = userpoints_get_transaction_row($transaction, $settings);
+  $result = $query->execute();
+  $transactions = userpoints_transaction_load_multiple($result->fetchCol());
+  foreach ($transactions as $transaction) {
+    $rows[] = $transaction->getTableRow($settings);
   }
 
   // Store context in the output array so that modules have access to it.
@@ -543,7 +560,7 @@ function userpoints_confirm_approve($form, $form_state, $operation, $transaction
     $description = format_plural($transaction->points, 'Do you want to approve @count !point for !user in the %category category?', 'Do you want to approve @count !points for !user in the %category category?', $arguments);
     $form['operation'] = array(
       '#type' => 'value',
-      '#value' => USERPOINTS_TXN_STATUS_APPROVED,
+      '#value' => UserpointsTransaction::STATUS_APPROVED,
     );
   }
   else {
@@ -551,7 +568,7 @@ function userpoints_confirm_approve($form, $form_state, $operation, $transaction
     $description = format_plural($transaction->points, 'Do you want to decline @count !point for !user in the %category category?', 'Do you want to decline @count !points for !user in the %category category?', $arguments);
     $form['operation'] = array(
       '#type' => 'value',
-      '#value' => USERPOINTS_TXN_STATUS_DECLINED,
+      '#value' => UserpointsTransaction::STATUS_DECLINED,
     );
   }
 
diff --git a/userpoints.api.php b/userpoints.api.php
index 0acb9e8..aa52c7a 100644
--- a/userpoints.api.php
+++ b/userpoints.api.php
@@ -6,6 +6,32 @@
  */
 
 /**
+ * @mainpage Userpoints API Documentation
+ * API Documentation of the Userpoints module.
+ *
+ * - Topics:
+ *  - @link userpoints_api API functions @endlink
+ *  - @link userpoints_hooks Provided hooks @endlink
+ */
+
+/**
+ * @defgroup userpoints_api API functions
+ *
+ * The function userpoints_grant_points() allows to grant a number of points,
+ * positive or negative, to a user.
+ */
+
+/**
+ * @defgroup userpoints_hooks Provided hooks
+ *
+ */
+
+/**
+ * @addtogroup userpoints_hooks
+ * @{
+ */
+
+/**
  * Return information about registered operations.
  *
  * Modules can register operation strings
@@ -81,4 +107,33 @@ function hook_userpoints_settings() {
     '#default_value' => userpoints_nc_get_setting('enabled', NULL, TRUE),
     '#description' => t('If checked, all content types award !points by default. This can be overridden for each content type on the content type edit page.', userpoints_translation()),
   );
-}
\ No newline at end of file
+}
+
+/**
+ * Invoked before a transaction is saved.
+ *
+ * To prohibit this transaction from being saved, use
+ * UserpointsTransaction::deny().
+ *
+ * @param $transaction
+ *   A UserpointsTransaction object which contains all information about the
+ *   new or updated transaction.
+ */
+function hook_userpoints_transaction_before(UserpointsTransaction $transaction) {
+
+}
+
+/**
+ * Invoked after a transaction has been saved.
+ *
+ * @param $transaction
+ *   A UserpointsTransaction object which contains all information about the
+ *   new or updated transaction.
+ */
+function hook_userpoints_transaction_after(UserpointsTransaction $transaction) {
+
+}
+
+/**
+ * @}
+ */
\ No newline at end of file
diff --git a/userpoints.info b/userpoints.info
index c6dae4f..0124073 100644
--- a/userpoints.info
+++ b/userpoints.info
@@ -3,7 +3,7 @@ description = Userpoints core: API module for recording points for other modules
 package = Userpoints
 core = 7.x
 files[]=userpoints.views.inc
-files[]=userpoints.module
+files[]=userpoints.transaction.inc
 files[]=tests/userpoints_api.test
 files[]=views/userpoints_views_handler_field_category.inc
 files[]=views/userpoints_views_handler_filter_category.inc
diff --git a/userpoints.module b/userpoints.module
index 8eb821e..6506e7f 100644
--- a/userpoints.module
+++ b/userpoints.module
@@ -11,11 +11,6 @@ define('USERPOINTS_STATUS', 'userpoints_status');
 
 define('USERPOINTS_POINTS_MODERATION', 'userpoints_points_moderation');
 
-define('USERPOINTS_TXN_STATUS_APPROVED', 0);
-define('USERPOINTS_TXN_STATUS_PENDING', 1);
-define('USERPOINTS_TXN_STATUS_DECLINED', 2);
-
-
 define('USERPOINTS_EXPIRY_DESCRIPTION', 'userpoints_expiry_description');
 define('USERPOINTS_EXPIREON_DATE', 'userpoints_expireon_date');
 define('USERPOINTS_EXPIREAFTER_DATE', 'userpoints_expireafter_date');
@@ -56,9 +51,9 @@ function userpoints_txn_status() {
   static $stati;
   if (empty($stati)) {
     $stati = array(
-      USERPOINTS_TXN_STATUS_APPROVED => t('Approved'),
-      USERPOINTS_TXN_STATUS_PENDING => t('Pending'),
-      USERPOINTS_TXN_STATUS_DECLINED => t('Declined'),
+      UserpointsTransaction::STATUS_APPROVED => t('Approved'),
+      UserpointsTransaction::STATUS_PENDING => t('Pending'),
+      UserpointsTransaction::STATUS_DECLINED => t('Declined'),
     );
   }
   return $stati;
@@ -357,8 +352,8 @@ function userpoints_menu_local_tasks_alter(&$data, $router_item, $root_path) {
  *
  * Only pending transactions can be approved or declined.
  */
-function userpoints_admin_access_transaction_pending($transaction) {
-  return $transaction->status == USERPOINTS_TXN_STATUS_PENDING && userpoints_admin_access('moderate');
+function userpoints_admin_access_transaction_pending(UserpointsTransaction $transaction) {
+  return $transaction->isPending() && userpoints_admin_access('moderate');
 }
 
 /**
@@ -383,14 +378,8 @@ function userpoints_access_my_points($account = NULL) {
  * @return
  *   TRUE if the user has permissions to view the transaction.
  */
-function userpoints_access_view_transaction($transaction) {
-  if (empty($transaction->user)) {
-    $account = user_load($transaction->uid);
-  }
-  else {
-    $account = $transaction->user;
-  }
-  return userpoints_access_my_points($account);
+function userpoints_access_view_transaction(UserpointsTransaction $transaction) {
+  return userpoints_access_my_points($transaction->getUser());
 }
 
 /**
@@ -585,6 +574,8 @@ function userpoints_token_info() {
  *
  * @return
  *   Number of current points in that user's account.
+ *
+ * @ingroup userpoints_api
  */
 function userpoints_get_current_points($uid = NULL, $tid = NULL) {
   $points = drupal_static(__FUNCTION__, array());
@@ -615,6 +606,8 @@ function userpoints_get_current_points($uid = NULL, $tid = NULL) {
  *
  * @return
  *   Number of max points in that user's account.
+ *
+ * @ingroup userpoints_api
  */
 function userpoints_get_max_points($uid = NULL, $tid = NULL) {
   $max = drupal_static(__FUNCTION__, array());
@@ -649,389 +642,57 @@ function userpoints_get_max_points($uid = NULL, $tid = NULL) {
 }
 
 /**
- * Save userpoints changes and call hooks.
+ * Grant a user points.
  *
- * @param $params
- *    if (int) assumed to be points for current user
- *    Accepts an array of keyed variables and parameters
- *    'points' => # of points (int) (required)
- *    'moderate' => TRUE/FALSE
- *    'uid' => $user->uid
- *    'time_stamp' => unix time of the points assignment date
- *    'operation' => 'published' 'moderated' etc.
- *    'tid' => 'category ID'
- *    'expirydate' => timestamp or 0, 0 = non-expiring; NULL = site default
- *    'description' => 'description'
- *    'reference' => reserved for module specific use
- *    'display' => whether or not to display "points awarded" message
- *    'txn_id' => Transaction ID of points, If present an UPDATE is performed
- *    'entity_id' => ID of an entity in the Database. ex. $node->id or $user->uid
- *    'entity_type' => string of the entity type. ex. 'node' or 'user' NOT 'node-content-custom'
+ * The function excpects two required parameters, an operation string
+ * and the amount of points. Optionally, a user id can be passed.
  *
- * @return
- *   Array with status and reason.
- *     'status' => FALSE when no action is take, TRUE when points are credited or debited
- *     'reason' => (string) error message to indicate reason for failure
- */
-function userpoints_userpointsapi($params) {
-  global $user;
-
-  // Test for the existence of parameters and set defaults if necessary.
-  if (!isset($params['txn_id'])) {
-    // If a txn_id is passed in we'll do an UPDATE thus the std checks don't apply.
-    if (is_int($params)) {
-      $params = array('points' => $params);
-    }
-    if (!is_array($params)) {
-      // Has to be an array to continue.
-      return array(
-          'status' => FALSE,
-          'reason' => 'Parameters did not properly form as an array,
-                     this is an internal module error.
-                    ',
-      );
-    }
-    if (!isset($params['uid'])) {
-      $params['uid'] = $user->uid;
-    }
-
-    // Check if parameters are set.
-    $params_null_check = array('operation', 'description', 'reference', 'display', 'entity_id', 'entity_type');
-    foreach ($params_null_check as $param_null_check) {
-      if (!isset($params[$param_null_check])) {
-        $params[$param_null_check] = NULL;
-      }
-    }
-
-    if (!isset($params['moderate'])) {
-      // If not passed then site default is used.
-      $params['status'] = variable_get(USERPOINTS_POINTS_MODERATION, USERPOINTS_TXN_STATUS_APPROVED);
-    }
-    else {
-      $params['status'] = $params['moderate'] ? USERPOINTS_TXN_STATUS_PENDING : USERPOINTS_TXN_STATUS_APPROVED;
-    }
-    if (!isset($params['tid']) || !is_numeric($params['tid'])) {
-      // If not passed then site default is used.
-      $params['tid'] = userpoints_get_default_tid();
-    }
-
-    // Anonymous users do not get points, and there have to be points to process.
-    if (empty($params['uid']) || empty($params['points'])) {
-      return array(
-          'status' => FALSE,
-          'reason' => 'uid or points not given. Anonymous users do not get points and there must be points to process.',
-      );
-    }
-  }
-  else {
-    // We have a txn_id so we can look up some user information.
-    $params['uid'] = db_query('SELECT uid from {userpoints_txn} WHERE txn_id = :txn_id', array(':txn_id' => $params['txn_id']))->fetchField();
-  } // If txn_id.
-  // Load the user object that will be awarded the points.
-  $account = user_load($params['uid']);
-  if (!$account) {
-    return array(
-        'status' => FALSE,
-        'reason' => 'invalid uid or user account could not be loaded',
-    );
-  }
-
-  // Call the _userpoints hook, and stop if one of them returns FALSE.
-  $rc = userpoints_invoke_all('points before', $params);
-
-  foreach ($rc as $key => $value) {
-    if ($value == FALSE) {
-      // Do not process the points.
-      return array(
-          'status' => FALSE,
-          'reason' => t('@key returned FALSE from the hook_userpoints points before call', array('@key' => $key)),
-      );
-    }
-  }
-
-  $ret = _userpoints_transaction($params);
-
-  // Reset the static cache of userpoints.
-  drupal_static_reset('userpoints_get_current_points');
-
-  if ($ret == FALSE) {
-    return array(
-        'status' => FALSE,
-        'reason' => 'transaction failed in _userpoints_transaction, this is an internal module error',
-    );
-  }
-
-  // Allow modules to define custom messages.
-  if (!empty($params['message'])) {
-    $message = $params['message'];
-  }
-  // Display message if either display property is not set and messages should
-  // be displayed by default or display property is not FALSE.
-  elseif (!empty($params['display']) || (!isset($params['display']) && variable_get(USERPOINTS_DISPLAY_MESSAGE, 1))) {
-    // Prepare arguments. They are the same for all string combinations.
-    $categories = userpoints_get_categories();
-    $arguments = array_merge(userpoints_translation(), array(
-      '!username' => theme('username', array('account' => $account)),
-      '%total' => userpoints_get_current_points($params['uid'], $params['tid']),
-      '%category' => isset($categories[$params['tid']]) ? $categories[$params['tid']] : $categories[0],
-    ));
-
-    $view_own_points = user_access('view own userpoints') || user_access('view userpoints') || user_access('administer userpoints');
-    $view_all_points = user_access('view userpoints') || user_access('administer userpoints');
-
-    if ($params['status'] == USERPOINTS_TXN_STATUS_DECLINED) {
-      // Points have been declined.
-      if ($account->uid == $user->uid && $view_own_points) {
-        $message = format_plural($params['points'], 'You did not receive approval for @count !point in the %category category.', 'You did not receive approval for @count !points in the %category category.', $arguments);
-      }
-      elseif ($view_all_points) {
-        $message = format_plural($params['points'], '!username did not receive approval for @count !point in the %category category.', '!username did not receive approval for @count !points in the %category category.', $arguments);
-      }
-    }
-    elseif (isset($params['points']) && $params['points'] < 0) {
-      if ($params['status'] == USERPOINTS_TXN_STATUS_PENDING) {
-        if ($account->uid == $user->uid && $view_own_points) {
-          // Directly address the user if he is loosing points.
-          $message = format_plural(abs($params['points']), 'You just had a !point deducted, pending administrator approval.', 'You just had @count !points deducted, pending administrator approval.', $arguments);
-        }
-        elseif ($view_all_points) {
-          // Only display message about other users if user has permission to view userpoints.
-          $message = format_plural(abs($params['points']), '!username just had a !point deducted, pending administrator approval.', '!username just had @count !points deducted, pending administrator approval.', $arguments);
-        }
-      }
-      else {
-        if ($account->uid == $user->uid && $view_own_points) {
-          $message = format_plural(abs($params['points']), 'You just had a !point deducted and now have %total !points in the %category category.', 'You just had @count !points deducted and now have %total !points in the %category category.', $arguments);
-        }
-        elseif ($view_all_points) {
-          $message = format_plural(abs($params['points']), '!username just had a !point deducted and now has %total !points in the %category category.', '!username just had @count !points deducted and now has %total !points in the %category category.', $arguments);
-        }
-      }
-    }
-    elseif (!empty($params['points'])) {
-      if ($params['status'] == USERPOINTS_TXN_STATUS_PENDING) {
-        if ($account->uid == $user->uid && $view_own_points) {
-          // Directly address the user if he is loosing points.
-          $message = format_plural(abs($params['points']), 'You just earned a !point, pending administrator approval.', 'You just earned @count !points, pending administrator approval.', $arguments);
-        }
-        elseif ($view_all_points) {
-          // Only display message about other users if user has permission to view userpoints.
-          $message = format_plural(abs($params['points']), '!username just earned a !point, pending administrator approval.', '!username just earned @count !points, pending administrator approval.', $arguments);
-        }
-      }
-      else {
-        if ($account->uid == $user->uid && $view_own_points) {
-          $message = format_plural(abs($params['points']), 'You just earned a !point and now have %total !points in the %category category.', 'You just earned @count !points and now have %total !points in the %category category.', $arguments);
-        }
-        elseif ($view_all_points) {
-          $message = format_plural(abs($params['points']), '!username just earned a !point and now has %total !points in the %category category.', '!username just earned @count !points and now has %total !points in the %category category.', $arguments);
-        }
-      }
-    }
-
-    if (isset($message)) {
-      drupal_set_message($message);
-    }
-  }
-  // Call the _userpoints hook to allow modules to act after points are awarded.
-  userpoints_invoke_all('points after', $params);
-  return array(
-    'status' => TRUE,
-    'transaction' => $params,
-  );
-}
-
-/**
- * Adds the points to the txn table.
- */
-function _userpoints_transaction(&$params) {
-  // Check, again, for a properly formed array.
-  if (!is_array($params)) {
-    return FALSE;
-  }
-  if (!isset($params['txn_id'])) {
-    // If a txn_id is preset we UPDATE the record instead of adding one
-    // the standard checks don't apply.
-    if (!is_numeric($params['points'])) {
-      return FALSE;
-    }
-    if (!isset($params['uid'])) {
-      global $user;
-      $params['uid'] = $user->uid;
-      // There must be a UID, anonymous does not receive points.
-      if (!$params['uid'] > 0) {
-        return FALSE;
-      }
-    }
-    if (isset($params['expirydate']) && !is_numeric($params['expirydate'])) {
-      return FALSE;
-    }
-
-    // Check if parameters are set.
-    $params_null_check = array('operation', 'description', 'reference', 'expired', 'parent_txn_id', 'entity_id', 'entity_type');
-    foreach ($params_null_check as $param_null_check) {
-      if (!isset($params[$param_null_check])) {
-        $params[$param_null_check] = NULL;
-      }
-    }
-
-    if (!isset($params['tid']) || !is_numeric($params['tid'])) {
-      $params['tid'] = userpoints_get_default_tid();
-    }
-    elseif ($params['tid'] == 0) {
-      // Tid with 0 are uncategorized and are set to NULL
-      // this is a backwards compatibility issue.
-      $params['tid'] = NULL;
-    }
-    if (!isset($params['expirydate'])) {
-      $params['expirydate'] = userpoints_get_default_expiry_date();
-    }
-
-    // Use current time for time_stamp if configured to always use the default,
-    // not set, not a positive integer or in the future.
-    if (variable_get(USERPOINTS_TRANSACTION_TIMESTAMP, 1) || !isset($params['time_stamp']) || $params['time_stamp'] <= 0 || $params['time_stamp'] > REQUEST_TIME) {
-      $params['time_stamp'] = REQUEST_TIME;
-    }
-  }
-  // Always force changed timestamp to current REQUEST_TIME for transaction tracking.
-  $params['changed'] = REQUEST_TIME;
-
-  if (!empty($params['txn_id']) && $params['txn_id'] > 0) {
-    // A transaction ID was passed in so we'll update the transaction.
-    $txn = (array) userpoints_transaction_load($params['txn_id']);
-    if (!$txn) {
-      return FALSE;
-    }
-
-    // Don't superseed existing keys, just complete missing keys.
-    $params += $txn;
-    // Update existing transaction record for key txn_id.
-    $ret = drupal_write_record('userpoints_txn', $params, array('txn_id'));
-    // Only update if the record has been successfully updated.
-    if ($ret != FALSE) {
-      entity_get_controller('userpoints_transaction')->resetCache(array($params['txn_id']));
-      _userpoints_update_cache($params, $txn);
-    }
-  }
-  else {
-    // Create new transaction record.
-    $ret = drupal_write_record('userpoints_txn', $params);
-    if ($ret != FALSE) {
-      _userpoints_update_cache($params);
-    }
-  }
-  return TRUE;
-}
-
-/**
- * Update the caching table.
+ * The function then returns a UserpointsTransaction object, which provides
+ * methods to add further details to the transaction. New transactions are saved
+ * automatically through the __destruct() method. However, if transaction needs
+ * to be saved immediately, save() can be called directly.
+ *
+ * Basic usage examples:
+ * @code
+ * // Adding points to the current user, relying on automatic saving.
+ * userpoints_grant_points('mymodule_type_action', $points);
  *
- * @param $params
- *   Array with the transaction params.
- * @param $txn
- *   The original transaction, if this is an update.
+ * // Grant points to another, add a entity reference to a node and save
+ * // explicitly.
+ * userpoints_grant_points('mymodule_type_otheraction', $points, $account->uid)
+ *   ->setEntity($node)
+ *   ->save();
+ * @endcode
+ *
+ * A list of all available methods can be found in the UserpointsTransaction
+ * documentation.
+ *
+ * @param $operation
+ *   A string that can identify this transaction. Can be used to provide a
+ *   custom, translatable, optionally dynamic reason for this transaction in
+ *   transaction listings. See hook_userpoints_info().
+ * @param $points
+ *   A positive or negative point amount that should be assigned to the user.
+ * @param $uid
+ *   UID of the user that should be granted points. Optional, defaults to the
+ *   current user.
+ *
+ * @return UserpointsTransaction
+ *
+ * @ingroup userpoints_api
  */
-function _userpoints_update_cache($txn, $old_txn = NULL) {
-  // Store eventual updates in this array.
-  $updates = array();
-  $totals = array();
-  if (!$old_txn) {
-    // For new transactions, only update the cache for fully approved non-expired
-    // points.
-    if ($txn['status'] == USERPOINTS_TXN_STATUS_APPROVED && $txn['expired'] != 1) {
-      // Calculate the current points based upon the tid.
-      $updates['points'] = $txn['points'] + userpoints_get_current_points($txn['uid'], $txn['tid']);
-      $totals['points'] = $txn['points'] + userpoints_get_current_points($txn['uid'], 'all');
-    }
-  } else  {
-    // For existing transactions, it is a bit more complex.
-
-    // Expired transactions that were expired before can be ignored.
-    if ($txn['expired'] == 1 && $old_txn['expired'] == 1) {
-      return;
-    }
-
-    if ($old_txn['tid'] != $txn['tid']) {
-      // If the category has changed, remove the points of the old transaction
-      // from the old category.
-      $remove_points = userpoints_get_current_points($txn['uid'], $old_txn['tid']) - $old_txn['points'];
-      db_merge('userpoints')
-        ->key(array(
-          'uid' => $txn['uid'],
-          'tid' => (int) $old_txn['tid'],
-        ))
-        ->fields(array(
-          'points' => $remove_points,
-        ))
-        ->execute();
-
-      // Subtract the points from the total.
-      $totals['points'] = userpoints_get_current_points($txn['uid'], 'all') - $old_txn['points'];
-
-      if ($txn['status'] == USERPOINTS_TXN_STATUS_APPROVED) {
-        // Make sure to add the points so that they are added to the new category.
-        $updates['points'] = userpoints_get_current_points($txn['uid'], $txn['tid']) +  $txn['points'];
-
-        // Add them to the totals.
-        $totals['points'] += $txn['points'];
-      }
-    }
-    else if ($old_txn['status'] == USERPOINTS_TXN_STATUS_APPROVED && $txn['status'] != USERPOINTS_TXN_STATUS_APPROVED) {
-      // If the transaction goes from approved to not approved, subtract the
-      // points to the total.
-      $updates['points'] = userpoints_get_current_points($txn['uid'], $txn['tid']) - $old_txn['points'];
-      $totals['points'] = userpoints_get_current_points($txn['uid'], 'all') - $old_txn['points'];
-    }
-    else if ($txn['points'] != $old_txn['points'] && $old_txn['status'] == USERPOINTS_TXN_STATUS_APPROVED && $txn['status'] == USERPOINTS_TXN_STATUS_APPROVED) {
-      // If the category did not change but the points and the transaction
-      // was and still is approved, update the points difference.
-      $updates['points'] = userpoints_get_current_points($txn['uid'], $txn['tid']) + ($txn['points'] - $old_txn['points']);
-      $totals['points'] = userpoints_get_current_points($txn['uid'], 'all') + ($txn['points'] - $old_txn['points']);
+function userpoints_grant_points($operation, $points, $uid = NULL) {
+  global $user;
 
-    }
-    elseif ($old_txn['status'] != USERPOINTS_TXN_STATUS_APPROVED && $txn['status'] == USERPOINTS_TXN_STATUS_APPROVED) {
-      // Calculate the current points based upon the tid.
-      $updates['points'] = userpoints_get_current_points($txn['uid'], $txn['tid']) + $txn['points'];
-      $totals['points'] = userpoints_get_current_points($txn['uid'], 'all') + $txn['points'];
-    }
-  }
-  if (!empty($updates)) {
-    $max_points = userpoints_get_max_points($txn['uid'], $txn['tid']);
-    // If the new points are higher then the maximum, update it.
-    if ($updates['points'] > $max_points) {
-      $updates['max_points'] = $updates['points'];
-    }
-    $updates['last_update'] = REQUEST_TIME;
-
-    // Insert or update the userpoints caching table with the user's current
-    // points.
-    db_merge('userpoints')
-      ->key(array(
-        'uid' => $txn['uid'],
-        'tid' => (int) $txn['tid'],
-      ))
-      ->fields($updates)
-      ->execute();
+  // Default to the current user if not set.
+  if (empty($uid)) {
+    $uid = $user->uid;
   }
 
-  // Update totals if necessary.
-  if (!empty($totals)) {
-    // Update the total max points if necessary.
-    $max_points_total = userpoints_get_max_points($txn['uid'], 'all');
-    if ($totals['points'] > $max_points_total) {
-      $totals['max_points'] = $totals['points'];
-    }
-    $totals['last_update'] = REQUEST_TIME;
-
-    // Insert or update the userpoints total caching table with the user's current
-    // points.
-    db_merge('userpoints_total')
-      ->key(array(
-        'uid' => $txn['uid'],
-      ))
-      ->fields($totals)
-      ->execute();
-  }
+  $transaction = new UserpointsTransaction();
+  return $transaction->setOperation($operation)
+    ->setPoints($points)
+    ->setUid($uid);
 }
 
 /**
@@ -1051,33 +712,6 @@ function userpoints_get_default_expiry_date() {
   return (int) $expirydate;
 }
 
-/*
- * Checks to ensure that a user exists corresponding to a category.
- *
- * @param $uid
- *   User ID to check for existence of points for the user.
- * @param $tid
- *   taxonomy id of the category to limit to, if omitted
- *   if the use has points in any category the return is TRUE.
- * @return
- *  TRUE if user found, FALSE otherwise.
- */
-
-function _userpoints_user_exists($uid, $tid = NULL) {
-  if (is_numeric($tid)) {
-    return (int) db_query('SELECT COUNT(uid)
-      FROM {userpoints}
-      WHERE uid = :uid AND tid = :tid',
-            array(':uid' => $uid, ':tid' => $tid))->fetchField();
-  }
-  else {
-    return (int) db_query('SELECT COUNT(uid)
-      FROM {userpoints}
-      WHERE uid = :uid',
-            array(':uid' => $uid))->fetchField();
-  }
-}
-
 /**
  * Implements hook_user_delete().
  */
@@ -1417,40 +1051,33 @@ function userpoints_date_to_timestamp($date) {
  * opposite transactions (sum of 0).
  */
 function userpoints_expire_transactions() {
-  $sql = "SELECT txn_id, uid, points, time_stamp, operation, description, tid
+  $sql = "SELECT txn_id
           FROM {userpoints_txn}
           WHERE status = 0 AND expired = 0
           AND (expirydate < :expiry_date AND expirydate != 0)";
-  $result = db_query($sql, array(':expiry_date' => REQUEST_TIME));
-  foreach ($result as $line) {
-    $time_stamp_formatted = format_date($line->time_stamp, 'custom', 'Y-m-d H:i');
+  $txn_ids = db_query($sql, array(':expiry_date' => REQUEST_TIME))->fetchCol();
+  foreach (userpoints_transaction_load_multiple($txn_ids) as $transaction) {
+    $time_stamp_formatted = format_date($transaction->time_stamp, 'custom', 'Y-m-d H:i');
     $arguments = array_merge(userpoints_translation(), array(
-      '!operation' => $line->operation,
-      '!description' => $line->description,
-      '!txn_id' => $line->txn_id,
+      '!operation' => $transaction->operation,
+      '!description' => $transaction->description,
+      '!txn_id' => $transaction->txn_id,
       '!date' => $time_stamp_formatted,
     ));
     $description = strtr(variable_get(USERPOINTS_EXPIRY_DESCRIPTION, NULL), $arguments);
 
-    $params = array(
-      'points' => -$line->points,
-      'uid' => $line->uid,
-      'operation' => 'expiry',
-      'description' => $description,
-      'parent_txn_id' => $line->txn_id,
-      'moderate' => FALSE,
-      'tid' => $line->tid,
-      'time_stamp' => $line->time_stamp,
-      'expirydate' => 0,
-    );
-    userpoints_userpointsapi($params);
-    // Ok we've expired the entry lets update the original entry to set the
-    // expired flag.
-    $params = array(
-        'txn_id' => $line->txn_id,
-        'expired' => 1,
-    );
-    userpoints_userpointsapi($params);
+    userpoints_grant_points('expiry', -$transaction->points, $transaction->uid)
+      ->setDescription($description)
+      ->setParent($transaction->txn_id)
+      ->setTid($transaction->tid)
+      ->setExpiryDate(0)
+      ->save();
+
+    // Ok we've expired the entry, now the original transaction needs to be
+    // marked as expired.
+    $transaction
+      ->setExpired(1)
+      ->save();
   }
 }
 
@@ -1502,6 +1129,8 @@ function userpoints_get_vid() {
 
 /**
  * Returns an array of possible categories, suitable for inclusion in FAPI.
+ *
+ * @ingroup userpoints_api
  */
 function userpoints_get_categories($account = NULL) {
   $cache = drupal_static(__FUNCTION__, array());
@@ -1555,33 +1184,6 @@ function userpoints_views_api() {
 }
 
 /**
- * Invokes hook_userpoints() with params passed by references.
- *
- * @param $op
- *   The operation being performed.
- * @param &$params
- *   Parameters to be passed to the hook.
- *
- * @return
- *   An array of return values of the hook implementations. If modules return
- *   arrays from their implementations, those are merged into one array.
- */
-function userpoints_invoke_all($op, &$params = array()) {
-  $return = array();
-  foreach (module_implements('userpoints') as $module) {
-    $function = $module .'_userpoints';
-    $result = $function($op, $params);
-    if (isset($result) && is_array($result)) {
-      $return = array_merge_recursive($return, $result);
-    }
-    else if (isset($result)) {
-      $return[] = $result;
-    }
-  }
-  return $return;
-}
-
-/**
  * Returns information about point-providing modules and operations.
  *
  * @see hook_userpoints_info()
@@ -1607,91 +1209,6 @@ function userpoints_get_info($operation = NULL) {
 }
 
 /**
- * Creates a descriptive reason for a userpoints_transaction.
- *
- * The following resources are considered, in this order:
- *
- *  * description key in the information array for that operation.
- *  * description of the transaction.
- *  * name of the operation.
- *
- * @param $transaction
- *   The transaction object for which the description shall be generated.
- *
- * @param $options
- *   Array of options:
- *   - link: If FALSE, no link is generated to the linked entity even if there
- *     were one. Defaults to TRUE.
- *   - truncate: Define if the reason should be truncated. Defaults to TRUE.
- *   - skip_description: Allows to skip the eventually existing custom
- *     description a transaction has and always uses the generated description.
- *
- * @return
- *   The reason for that transaction, linked to the referenced
- *   entity if available.
- */
-function userpoints_create_description($transaction, array $options = array()) {
-
-  // Default options.
-  $options += array(
-    'link' => TRUE,
-    'truncate' => TRUE,
-  );
-
-  // Check if there is a valid entity referenced and which can be linked to.
-  $entity = NULL;
-  if ($transaction->entity_type && entity_get_info($transaction->entity_type)) {
-    $entity = entity_load($transaction->entity_type, array($transaction->entity_id));
-    $entity = reset($entity);
-  }
-  $safe = FALSE;
-  // Check transaction description first to allow custom overrides.
-  if (!empty($transaction->description) && empty($options['skip_description'])) {
-    $description = $transaction->description;
-  } else {
-    $info = userpoints_get_info($transaction->operation);
-    // Check if there is a valid description callback defined for this
-    // operation.
-    if (!empty($info['description callback']) && function_exists($info['description callback'])) {
-      $description = $info['description callback']($transaction, $entity);
-      $safe = TRUE;
-    }
-    // Try static description key.
-    elseif (!empty($info['description'])) {
-      $description = $info['description'];
-      $safe = TRUE;
-    }
-  }
-  // Fallback to the operation name if there is no source.
-  if (empty($description)) {
-    $description = $transaction->operation;
-  }
-
-  // Truncate description.
-  $attributes = array();
-  $stripped_description = strip_tags($description);
-  if ($options['truncate'] && drupal_strlen($stripped_description) > variable_get('userpoints_truncate', 30) + 3) {
-    // The title attribute will be check_plain()'d again drupal_attributes(),
-    // avoid double escaping.
-    $attributes['title'] = html_entity_decode($stripped_description, ENT_QUOTES);
-    $description = truncate_utf8($stripped_description, variable_get('userpoints_truncate', 30), FALSE, TRUE);
-  }
-
-  // Link to the referenced entity, if available.
-  if ($entity && $options['link']) {
-    $uri = entity_uri($transaction->entity_type, $entity);
-    if ($uri) {
-      $description = l($description, $uri['path'], $uri['options'] + array('html' => $safe, 'attributes' => $attributes));
-    }
-  }
-  if ((empty($entity) || empty($uri)) && !$safe) {
-    // Escape possible user provided reason.
-    $description = check_plain($description);
-  }
-  return $description;
-}
-
-/**
  * Implements hook_userpoints_info().
  */
 function userpoints_userpoints_info() {
@@ -1709,7 +1226,7 @@ function userpoints_userpoints_info() {
  * @param $txn_id
  *   Userpoints transaction Id.
  *
- * @return
+ * @return UserpointsTransaction
  *   A loaded userpoints transaction object.
  */
 function userpoints_transaction_load($txn_id, $reset = FALSE) {
@@ -1723,38 +1240,6 @@ function userpoints_transaction_load_multiple(array $txn_ids, $conditions = arra
 }
 
 /**
- * Returns a list of operations as links.
- *
- * @param $transaction
- *   Transaction object.
- *
- * @param $show_view
- *   FALSE if the view link should not be displayed. Defaults to TRUE.
- *
- * @return
- *   A string with operation links.
- */
-function userpoints_get_transaction_actions($transaction, $show_view = TRUE) {
-  $actions = array();
-  $url_options = array('query' => drupal_get_destination());
-
-  $uri = userpoints_transaction_uri_callback($transaction, TRUE);
-  $url_prefix = $uri['path'];
-
-  if ($show_view && userpoints_access_view_transaction($transaction)) {
-    $actions[] = l('view', $url_prefix . '/view');
-  }
-  if (userpoints_admin_access('edit')) {
-    $actions[] = l('edit', $url_prefix . '/edit', $url_options);
-  }
-  if (userpoints_admin_access('moderate') && $transaction->status == USERPOINTS_TXN_STATUS_PENDING) {
-    $actions[] = l('approve', $url_prefix . '/approve', $url_options);
-    $actions[] = l('decline', $url_prefix . '/decline', $url_options);
-  }
-  return implode(' ', $actions);
-}
-
-/**
  * Returns a table header for a transaction listing.
  *
  * @param $settings
@@ -1832,74 +1317,6 @@ function userpoints_get_list_row($data) {
 }
 
 /**
- * Returns a single row for a transaction listing.
- * @param $transaction
- *   Transaction object.
- * @param $settings
- *   Array with settings about which column shall be displayed. All settings
- *   default to TRUE.
- *   - show_category, show category column.
- *   - show_user, show user column.
- *   - show_status, show status column.
- * @return
- *   A table row array for use with theme_table().
- */
-function userpoints_get_transaction_row($transaction, $settings = array()) {
-  $settings += array(
-    'show_user' => TRUE,
-    'show_status' => TRUE,
-  );
-
-  $stati = userpoints_txn_status();
-  $css_stati = array(
-    USERPOINTS_TXN_STATUS_APPROVED => 'approved',
-    USERPOINTS_TXN_STATUS_DECLINED => 'declined',
-    USERPOINTS_TXN_STATUS_PENDING => 'pending',
-  );
-  $row = array('class' => array(
-    'userpoints-transaction-row-status-' . $css_stati[$transaction->status],
-    'userpoints-transaction-row-category-' . $transaction->tid),
-  );
-  if ($settings['show_user']) {
-    $row['data'][] = array(
-      'data' => theme('username', array('account' => user_load($transaction->uid))),
-      'class' => array('userpoints-transactions-field-user'),
-    );
-  }
-  $row['data'][] = array(
-    'data' => $transaction->points,
-    'class' => array('userpoints-transactions-field-points', 'userpoints-transaction-points-' . ($transaction->points > 0 ? 'positive' : 'negative')),
-  );
-
-  $categories = userpoints_get_categories();
-  if (count($categories) > 1) {
-    $row['data'][] = array(
-      'data' => isset($categories[$transaction->tid]) ? $categories[$transaction->tid] : $categories[0],
-      'class' => array('userpoints-transactions-field-category'),
-    );
-  }
-  $row['data'][] = array(
-    'data' => format_date($transaction->time_stamp, 'small'),
-    'class' => array('userpoints-transactions-field-timestamp'),
-  );
-  $row['data'][] = array(
-    'data' => userpoints_create_description($transaction),
-    'class' => array('userpoints-transactions-field-reason'),
-  );
-  if ($settings['show_status']) {
-    $row['data'][] = array(
-      'data' => $stati[$transaction->status],
-      'class' => array('userpoints-transactions-field-status'),
-    );
-  }
-  $row['data'][] = array(
-    'data' => userpoints_get_transaction_actions($transaction),
-    'class' => array('userpoints-transactions-field-actions'),
-  );
-  return $row;
-}
-
-/**
  * Implements hook_entity_property_info().
  */
 function userpoints_entity_property_info() {
@@ -1911,14 +1328,16 @@ function userpoints_entity_property_info() {
       'type' => 'user',
       'label' => t('User'),
       'description' => t('The user that will receive the !points', userpoints_translation()),
-      'setter callback' => 'entity_property_verbatim_set',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'points' => array(
       'type' => 'integer',
       'label' => t('!Points', userpoints_translation()),
       'description' => t('Amount of !points to give or take.', userpoints_translation()),
       'restriction' => 'input',
-      'setter callback' => 'entity_property_verbatim_set',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'points_abs' => array(
       'label' => t('!Points absolute', userpoints_translation()),
@@ -1932,7 +1351,8 @@ function userpoints_entity_property_info() {
       'type' => 'integer',
       'options list' => 'userpoints_rules_get_categories',
       'restriction' => 'input',
-      'setter callback' => 'entity_property_verbatim_set',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'entity' => array(
       'label' => t('Entity'),
@@ -1940,7 +1360,7 @@ function userpoints_entity_property_info() {
       'description' => t('The entity to which this transaction refers.'),
       'restriction' => 'input',
       'optional' => TRUE,
-      'getter callback' => 'entity_property_verbatim_get',
+      'getter callback' => 'userpoints_transaction_property_get',
      ),
     'description' => array(
       'label' => t('Description'),
@@ -1948,61 +1368,88 @@ function userpoints_entity_property_info() {
       'description' => t('Can contain the reason why the points have been given.'),
       'restriction' => 'input',
       'optional' => TRUE,
-      'setter callback' => 'entity_property_verbatim_set',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'reference' => array(
       'label' => t('Reference'),
       'type' => 'text',
       'description' => t('Can contain a reference for this transaction.'),
       'optional' => TRUE,
-      'setter callback' => 'entity_property_verbatim_set',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'operation' => array(
       'label' => t('Operation'),
       'type' => 'text',
       'description' => t('Describes the operation (Insert/Remove/...).'),
       'restriction' => 'input',
-      'setter callback' => 'entity_property_verbatim_set',
+      'getter callback' => 'userpoints_transaction_property_get',
+      'setter callback' => 'userpoints_transaction_property_set',
     ),
     'reason' => array(
       'label' => t('Reason'),
       'type' => 'text',
       'description' => t('The reason why the points were granted.'),
       'restriction' => 'input',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'time_stamp' => array(
       'label' => t('Timestamp'),
       'type' => 'date',
       'description' => t('Time when the points were given.'),
-      'setter callback' => 'entity_property_verbatim_set',
-      'getter callback' => 'entity_property_verbatim_get',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'expirydate' => array(
       'label' => t('Expiry date'),
       'type' => 'date',
       'description' => t('Time when the points will expire.'),
-      'setter callback' => 'entity_property_verbatim_set',
-      'getter callback' => 'entity_property_verbatim_get',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'display' => array(
       'label' => t('Display'),
       'type' => 'boolean',
       'description' => t('Whether to show a message to the user for this transaction or not.'),
-      'setter callback' => 'entity_property_verbatim_set',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
     'status' => array(
       'label' => t('Status'),
       'type' => 'integer',
       'description' => t('Status of this transaction.'),
       'options list' => 'userpoints_txn_status',
-      'setter callback' => 'entity_property_verbatim_set',
-      'getter callback' => 'entity_property_verbatim_get',
+      'setter callback' => 'userpoints_transaction_property_set',
+      'getter callback' => 'userpoints_transaction_property_get',
     ),
   );
   return $info;
 }
 
 /**
+ * Gets the property from the UserpointsTransaction object.
+ */
+function userpoints_transaction_property_get($data, array $options, $name) {
+  $method = 'get' . str_replace('_', '', $name);
+  if (method_exists($data, $method)) {
+    $value = $data->$method();
+    return $value;
+  }
+  return NULL;
+}
+/**
+ * Gets the property from the UserpointsTransaction object.
+ */
+function userpoints_transaction_property_set($data, $name, $value) {
+  $method = 'set' . str_replace('_', '', $name);
+  if (method_exists($data, $method)) {
+    return $data->$method($value);
+  }
+  return NULL;
+}
+
+/**
  * Gets the absolute points from a transaction..
  */
 function userpoints_transaction_get_points_absolute($userpoints_transaction, array $options, $name) {
@@ -2078,16 +1525,78 @@ function userpoints_transaction_uri_callback($transaction, $prefix_only = FALSE)
  * Userpoints transaction controller.
  */
 class UserpointsTransactionController extends DrupalDefaultEntityController {
+    
+  /**
+   * Overrides DrupalDefaultEntityController::load().
+   */
+  public function load($ids = array(), $conditions = array()) {
+    $entities = array();
+
+    // Revisions are not statically cached, and require a different query to
+    // other conditions, so separate the revision id into its own variable.
+    if ($this->revisionKey && isset($conditions[$this->revisionKey])) {
+      $revision_id = $conditions[$this->revisionKey];
+      unset($conditions[$this->revisionKey]);
+    }
+    else {
+      $revision_id = FALSE;
+    }
+
+    // Create a new variable which is either a prepared version of the $ids
+    // array for later comparison with the entity cache, or FALSE if no $ids
+    // were passed. The $ids array is reduced as items are loaded from cache,
+    // and we need to know if it's empty for this reason to avoid querying the
+    // database when all requested entities are loaded from cache.
+    $passed_ids = !empty($ids) ? array_flip($ids) : FALSE;
+    // Try to load entities from the static cache, if the entity type supports
+    // static caching.
+    if ($this->cache && !$revision_id) {
+      $entities += $this->cacheGet($ids, $conditions);
+      // If any entities were loaded, remove them from the ids still to load.
+      if ($passed_ids) {
+        $ids = array_keys(array_diff_key($passed_ids, $entities));
+      }
+    }
 
-  protected function attachLoad(&$transactions, $revision_id = FALSE) {
-    $categories = userpoints_get_categories();
-    foreach ($transactions as $transaction) {
-        // Load corresponding user object.
-        $transaction->user = user_load($transaction->uid);
+    // Load any remaining entities from the database. This is the case if $ids
+    // is set to FALSE (so we load all entities), if there are any ids left to
+    // load, if loading a revision, or if $conditions was passed without $ids.
+    if ($ids === FALSE || $ids || $revision_id || ($conditions && !$passed_ids)) {
+      // Build the query.
+      $query = $this->buildQuery($ids, $conditions, $revision_id);
+      $queried_entities = array();
+      $result = $query->execute();
+      foreach ($result->fetchAllAssoc($this->idKey, PDO::FETCH_ASSOC) as $row) {
+        $queried_entities[$row[$this->idKey]] = new UserpointsTransaction($row);
+      }
+    }
 
-        // Load category.
-        $transaction->category = isset($categories[$transaction->tid]) ? $categories[$transaction->tid] : $categories[userpoints_get_default_tid()];
+    // Pass all entities loaded from the database through $this->attachLoad(),
+    // which attaches fields (if supported by the entity type) and calls the
+    // entity type specific load callback, for example hook_node_load().
+    if (!empty($queried_entities)) {
+      $this->attachLoad($queried_entities, $revision_id);
+      $entities += $queried_entities;
     }
-    parent::attachLoad($transactions, $revision_id);
+
+    if ($this->cache) {
+      // Add entities to the cache if we are not loading a revision.
+      if (!empty($queried_entities) && !$revision_id) {
+        $this->cacheSet($queried_entities);
+      }
+    }
+
+    // Ensure that the returned array is ordered the same as the original
+    // $ids array if this was passed in and remove any invalid ids.
+    if ($passed_ids) {
+      // Remove any invalid ids from the array.
+      $passed_ids = array_intersect_key($passed_ids, $entities);
+      foreach ($entities as $entity) {
+        $passed_ids[$entity->{$this->idKey}] = $entity;
+      }
+      $entities = $passed_ids;
+    }
+
+    return $entities;
   }
-}
\ No newline at end of file
+}
diff --git a/userpoints.pages.inc b/userpoints.pages.inc
index c711ef5..62f7071 100644
--- a/userpoints.pages.inc
+++ b/userpoints.pages.inc
@@ -35,7 +35,7 @@ function userpoints_list_transactions($form, &$form_state, $account = NULL, $tid
   $header = userpoints_get_transaction_header($settings);
 
   $query = db_select('userpoints_txn', 'p')->extend('PagerDefault')->extend('TableSort')
-    ->fields('p')
+    ->fields('p', array('txn_id'))
     ->condition('p.uid', $account->uid)
     ->orderByHeader($header)
     // Enforce consistent sort order.
@@ -48,7 +48,7 @@ function userpoints_list_transactions($form, &$form_state, $account = NULL, $tid
 
   $unapproved_query = db_select('userpoints_txn', 'p')
     ->condition('uid', $account->uid)
-    ->condition('status', USERPOINTS_TXN_STATUS_PENDING);
+    ->condition('status', UserpointsTransaction::STATUS_PENDING);
   $unapproved_query->addExpression('SUM(points)');
 
   $values = userpoints_filter_parse_input($form_state, $tid);
@@ -65,8 +65,10 @@ function userpoints_list_transactions($form, &$form_state, $account = NULL, $tid
   }
 
   $rows = array();
-  foreach ($query->execute() as $transaction) {
-    $rows[] = userpoints_get_transaction_row($transaction, $settings);
+  $result = $query->execute();
+  $transactions = userpoints_transaction_load_multiple($result->fetchCol());
+  foreach ($transactions as $transaction) {
+    $rows[] = $transaction->getTableRow($settings);
   }
 
   // Store context in the output array so that modules have access to it.
@@ -219,9 +221,9 @@ function userpoints_view_transaction($transaction) {
   drupal_set_title(t('View transaction #@txn_id', array('@txn_id' => $transaction->txn_id)));
 
   $css_stati = array(
-    USERPOINTS_TXN_STATUS_APPROVED => 'approved',
-    USERPOINTS_TXN_STATUS_DECLINED => 'declined',
-    USERPOINTS_TXN_STATUS_PENDING => 'pending',
+    UserpointsTransaction::STATUS_APPROVED => 'approved',
+    UserpointsTransaction::STATUS_DECLINED => 'declined',
+    UserpointsTransaction::STATUS_PENDING => 'pending',
   );
   $classes = 'userpoints-view-' . $css_stati[$transaction->status] . ' userpoints-view-category-' . $transaction->tid . ' userpoints-view-' . ($transaction->points > 0 ? 'positive' : 'negative');
   if (!empty($transaction->expirydate)) {
@@ -267,7 +269,7 @@ function userpoints_view_transaction($transaction) {
   $content['details']['reason'] = array(
     '#theme' => 'userpoints_view_item',
     '#title' => t('Reason'),
-    '#value' => userpoints_create_description($transaction, array('truncate' => FALSE)),
+    '#value' => $transaction->getReason(array('truncate' => FALSE)),
     '#weight' => 30,
     '#attributes' => array('class' => array('userpoints-item-reason')),
   );
@@ -331,14 +333,16 @@ function userpoints_view_transaction($transaction) {
 
   if (!empty($transaction->parent_txn_id)) {
     $parent_transaction = userpoints_transaction_load($transaction->parent_txn_id);
-    $parent = l(userpoints_create_description($parent_transaction, array('link' => FALSE)), 'userpoints/view/' . $transaction->parent_txn_id, array('html' => TRUE));
+    $uri = entity_uri('userpoints_transaction', $parent_transaction);
+    $parent = l($parent_transaction->getReason(array('link' => FALSE)), $uri['path'], array('html' => TRUE));
   }
 
   $child_txn_ids = db_query('SELECT txn_id FROM {userpoints_txn} WHERE parent_txn_id = :txn_id', array(':txn_id' => $transaction->txn_id))->fetchCol();
   $children = array();
   foreach ($child_txn_ids as $child_txn_id) {
     $child_transaction = userpoints_transaction_load($child_txn_id);
-    $children[] = l(userpoints_create_description($child_transaction, array('link' => FALSE)), 'userpoints/view/' . $child_txn_id, array('html' => TRUE));
+    $uri = entity_uri('userpoints_transaction', $child_transaction);
+    $children[] = l($child_transaction->getReason(array('link' => FALSE)), $uri['path'], array('html' => TRUE));
   }
   $children = !empty($children) ? theme('item_list', array('items' => $children)) : '';
 
@@ -383,7 +387,7 @@ function userpoints_view_transaction($transaction) {
       $content['admin']['moderator'] = array(
         '#theme' => 'userpoints_view_item',
         '#title' => t('Moderator'),
-        '#value' => theme('username', array('account' => user_load($transaction->approver_uid))),
+        '#value' => theme('username', array('account' => $transaction->getApprover())),
         '#weight' => 0,
         '#attributes' => array('class' => array('userpoints-item-moderator')),
       );
@@ -401,7 +405,7 @@ function userpoints_view_transaction($transaction) {
       $content['admin']['description_generated'] = array(
         '#theme' => 'userpoints_view_item',
         '#title' => t('Description (auto generated)'),
-        '#value' => userpoints_create_description($transaction, array('skip_description' => TRUE, 'truncate' => FALSE)),
+        '#value' => $transaction->getReason(array('skip_description' => TRUE, 'truncate' => FALSE)),
         '#weight' => 20,
         '#attributes' => array('class' => array('userpoints-item-description-generated')),
       );
@@ -428,7 +432,7 @@ function userpoints_view_transaction($transaction) {
     $content['admin']['actions'] = array(
       '#theme' => 'userpoints_view_item',
       '#title' => t('Actions'),
-      '#value' => userpoints_get_transaction_actions($transaction, FALSE),
+      '#value' => $transaction->getActions(FALSE),
       '#weight' => 50,
       '#attributes' => array('class' => array('userpoints-item-actions')),
     );
diff --git a/userpoints.transaction.inc b/userpoints.transaction.inc
new file mode 100644
index 0000000..07869c7
--- /dev/null
+++ b/userpoints.transaction.inc
@@ -0,0 +1,1309 @@
+<?php
+
+/**
+ * @file
+ * Contains the UserpointsTransaction and related classes.
+ */
+
+/**
+ * A Userpoints transaction.
+ *
+ * @ingroup userpoints_api
+ */
+class UserpointsTransaction {
+
+  /**
+   * The transaction has been approved.
+   */
+  const STATUS_APPROVED = 0;
+  /**
+   * The transaction is pending for approval.
+   */
+  const STATUS_PENDING = 1;
+
+  /**
+   * The transaction has been declined.
+   */
+  const STATUS_DECLINED = 2;
+
+  /**
+   * The transaction id (primary key) of this transaction
+   *
+   * @var int
+   */
+  protected $txn_id = NULL;
+
+  /**
+   * The actual fields of this transaction.
+   *
+   * Stored in this array to simplify the save/update process.
+   *
+   * @var array
+   */
+  protected $fields = array();
+
+  /**
+   * Deny reasons.
+   *
+   * @var array
+   */
+  protected $denied_reasons = array();
+
+  /**
+   * TRUE if the transaction should display a message.
+   *
+   * @var boolean
+   */
+  protected $display = TRUE;
+
+  /**
+   * Overriden message, is used instead of the default in
+   * UserpointsTransaction::getReason() if existend.
+   *
+   * @var string
+   */
+  protected $message;
+
+  /**
+   * The original status of this transaction, used for denying changes to this
+   * transaction if is is not pending anymore.
+   *
+   * @var int
+   */
+  protected $orig_status;
+
+  /**
+   * If a transaction is aborted, it will not be saved automatically.
+   *
+   * Automatically set whenever an exception occurs.
+   */
+  protected $aborted = FALSE;
+
+  /**
+   * Start a new transaction or update an existing one.
+   *
+   * @param $txn_id
+   *   Transaction id if an existing transaction should be loaded.
+   */
+  function __construct($values = FALSE) {
+    if ($values) {
+
+      // The txn_id is stored in a separate property.
+      $this->txn_id = $values['txn_id'];
+      unset($values['txn_id']);
+
+      // All other values are stored in the fields property.
+      $this->fields = $values;
+      $this->orig_status = $this->getStatus();
+    }
+    else {
+      // Initalize fields.
+      $this->fields = array(
+        'uid' => 0,
+        'points' => NULL,
+        'operation' => NULL,
+        'status' => variable_get('userpoints_points_moderation', UserpointsTransaction::STATUS_APPROVED),
+        'tid' => userpoints_get_default_tid(),
+        'expirydate' => userpoints_get_default_expiry_date(),
+        'expired' => 0,
+        'time_stamp' => REQUEST_TIME,
+        'changed' => REQUEST_TIME,
+        'approver_uid' => 0,
+        'description' => '',
+        'reference' => '',
+        'parent_txn_id' => 0,
+        'entity_type' => NULL,
+        'entity_id' => 0,
+      );
+    }
+  }
+
+  /**
+   * Marks this transaction as aborted.
+   */
+  function abort() {
+    $this->aborted = TRUE;
+  }
+
+  /**
+   * Checks if this transaction is aborted.
+   */
+  function isAborted() {
+    return $this->aborted;
+  }
+
+  /**
+   * Define the referenced entity.
+   *
+   * @param $entity_type
+   *   Entity type that should be referenced.
+   * @param $entity_id
+   *   Id of the referenced entity.
+   *
+   * @return UserpointsTransaction
+   */
+  function setEntity($entity_type, $entity_id) {
+    $this->checkChange();
+
+    // Ignore empty values.
+    if (empty($entity_type) || empty($entity_id)) {
+      return $this;
+    }
+
+    $this->fields['entity_type'] = $entity_type;
+    $this->fields['entity_id'] = $entity_id;
+    return $this;
+  }
+
+  /**
+   * Add a free reference text to this transaction.
+   *
+   * @param $reference
+   *   A string that serves as an internal reference for this transaction.
+   *
+   * @return UserpointsTransaction
+   */
+  function setReference($reference) {
+    $this->checkChange();
+
+    $this->fields['reference'] = $reference;
+    return $this;
+  }
+
+  /**
+   * Add a description to this transaction.
+   *
+   * Note that modules should instead implement hook_userpoints_info() and
+   * provide a description for their operations. If a description is present, it
+   * will be displayed instead of a description provided through the mentioned
+   * hook.
+   *
+   * @param $description
+   *   A description for this transaction.
+   *
+   * @return UserpointsTransaction
+   */
+  function setDescription($description) {
+    $this->checkChange();
+
+    $this->fields['description'] = $description;
+    return $this;
+  }
+
+  /**
+   * Set the status for a transaction.
+   *
+   * There are helper functions available to set the status of a transaction to
+   * a specific status, e. g. UserpointsTransaction::pending(). It is
+   * recommended to use these instead.
+   *
+   * @param $status
+   *   One of the following constants: UserpointsTransaction::STATUS_APPROVED,
+   *   UserpointsTransaction::STATUS_DECLINED,
+   *   UserpointsTransaction::STATUS_PENDING.
+   *
+   * @return UserpointsTransaction
+   *
+   * @see UserpointsTransaction::pending()
+   * @see UserpointsTransaction::approve()
+   * @see UserpointsTransaction::decline()
+   *
+   */
+  function setStatus($status) {
+    $this->checkChange();
+
+    // Check allowed values.
+    if (!in_array($status, array(UserpointsTransaction::STATUS_APPROVED, UserpointsTransaction::STATUS_DECLINED, UserpointsTransaction::STATUS_PENDING))) {
+      $this->abort();
+      throw new UserpointsChangeException(t('Invalid status'));
+    }
+
+    if ($this->txn_id > 0) {
+      // Preserve the original status to be able to check if changes in this
+      // transaction are still allowed.
+      $this->orig_status = $this->getStatus();
+    }
+
+    $this->fields['status'] = $status;
+    return $this;
+  }
+
+  /**
+   * Set the expiration date of a transaction.
+   *
+   * Setting it to a date in the past will immediatly expire the transaction.
+   *
+   * @param $expirydate
+   *   Timestamp of the expiration date.
+   *
+   * @return UserpointsTransaction
+   */
+  function setExpiryDate($expirydate) {
+    $this->checkChange();
+
+    if ($expirydate > 0 || $expirydate === 0 || $expirydate === NULL) {
+      $this->fields['expirydate'] = (int)$expirydate;
+    }
+    else {
+      $this->abort();
+      throw new UserpointsInvalidArgumentException(t('Expiration date must be an integer'));
+    }
+    return $this;
+  }
+
+  /**
+   * Marks a transaction as expired.
+   *
+   * This does not affect the points total, instead, a reverting transaction
+   * must be created, see userpoints_expire_transactions().
+   *
+   * @param $expired
+   *   TRUE if the transaction should be marked as expired, FALSE if not.
+   *
+   * @return UserpointsTransaction
+   */
+  function setExpired($expired) {
+
+    // A transaction can always be expired but this can not be reversed.
+    if (!$expired && $this->fields['expired']) {
+      $this->checkChange();
+    }
+    $this->fields['expired'] = $expired;
+    return $this;
+  }
+
+  /**
+   * The user id of the user to which this transaction belongs.
+   *
+   * @param $uid
+   *   The user id.
+   *
+   * @return UserpointsTransaction
+   */
+  function setUid($uid) {
+    $this->checkChange();
+    $this->fields['uid'] = $uid;
+    return $this;
+  }
+
+  /**
+   * Set the user who approved this transaction.
+   *
+   * @param $uid
+   *   The user id of the approver.
+   *
+   * @return UserpointsTransaction
+   */
+  function setApproverUid($uid) {
+    $this->checkChange();
+    $this->fields['approver_uid'] = (int)$uid;
+    return $this;
+  }
+
+  /**
+   * Define the points amount of this transaction, which can be any positive
+   * or negative amount but not 0.
+   *
+   * @param $points
+   *   The points as an integer.
+   *
+   * @return UserpointsTransaction
+   */
+  function setPoints($points) {
+    $this->checkChange();
+
+    // Empty points amount is not allowed.
+    if (empty($points)) {
+      $this->abort();
+      throw new UserpointsInvalidArgumentException();
+    }
+
+    $this->fields['points'] = $points;
+    return $this;
+  }
+
+  /**
+   * Set the creation date of this transaction.
+   *
+   * This can only be set if the userpoints_transaction_timestamp variable is
+   * set to false. If that is set to true, the current timestamp is always
+   * enforced.
+   *
+   * @param $time_stamp
+   *   The timestamp of the transaction.
+   *
+   * @return UserpointsTransaction
+   */
+  function setTimestamp($time_stamp) {
+    $this->checkChange(TRUE);
+
+    if (variable_get('userpoints_transaction_timestamp', 1)) {
+      return $this;
+    }
+    $this->fields['time_stamp'] = $time_stamp;
+    return $this;
+  }
+
+  /**
+   * Define a parent transaction for this.
+   *
+   * For example, when expiring another transaction, this allows to add a
+   * reference to the expired transaction.
+   *
+   * @param $txn_id
+   *   The transaction id of the referenced transaction.
+   *
+   * @return UserpointsTransaction
+   */
+  function setParent($txn_id) {
+    $this->checkChange();
+
+    $this->fields['parent_txn_id'] = $txn_id;
+    return $this;
+  }
+
+  /**
+   * Set the category (term tid) of this transaction.
+   *
+   * @param $tid
+   *   The tid, a term id.
+   *
+   * @return UserpointsTransaction
+   */
+  function setTid($tid) {
+    $this->checkChange();
+    $this->fields['tid'] = $tid;
+    return $this;
+  }
+
+  /**
+   * Set the operation string for this transaction.
+   *
+   * A string that can identify this transaction. Can be used to provide a
+   * custom, translatable, optionally dynamic reason for this transaction in
+   * transaction listings. See hook_userpoints_info().
+   *
+   * This typically indicates the reason for this transaction, e.g. the user
+   * commented, voted, logged in etc.
+   *
+   * This should be understood as a machine name, e.g. mymodule_category_action.
+   *
+   * @param $operation
+   *   A string to identify this type of transaction.
+   *
+   * @return UserpointsTransaction
+   */
+  function setOperation($operation) {
+    $this->checkChange();
+
+    $this->fields['operation'] = $operation;
+    return $this;
+  }
+
+  /**
+   * Define if a message should be displayed to the user about this transaction.
+   *
+   * This can also be overriden by the userpoints_display_message setting. If
+   * that setting is disabled, messages are never displayed.
+   *
+   * @param $display
+   *   TRUE if a message should be displayed, FALSE if not. Defaults to TRUE.
+   *
+   * @return UserpointsTransaction
+   */
+  function setDisplay($display) {
+    $this->display = $display;
+    return $this;
+  }
+
+  /**
+   * Get the referenced entity, if any.
+   *
+   * @return
+   *   An entity object or NULL.
+   */
+  function getEntity() {
+    if (!empty($this->fields['entity_id']) && !empty($this->fields['entity_type']) && entity_get_info($this->fields['entity_type'])) {
+      return array_shift(entity_load($this->fields['entity_type'], array($this->fields['entity_id'])));
+    }
+  }
+
+  /**
+   * Get the referenced entity type, if any.
+   *
+   * @return
+   *   The entity type as a string.
+   */
+  function getEntityType() {
+    return $this->fields['entity_type'];
+  }
+
+  /**
+   * Get the referenced entity id, if any.
+   *
+   * @return
+   *   The entity id as an integer.
+   */
+  function getEntityId() {
+    return $this->fields['entity_id'];
+  }
+
+  /**
+   * The reference string of this transaction, if defined.
+   *
+   * @return
+   *   A reference string or NULL.
+   *
+   * @see UserpointsTransaction::setReference()
+   */
+  function getReference() {
+    return $this->fields['reference'];
+  }
+
+  /**
+   * The description string of this transaction, if defined.
+   *
+   * @return
+   *   A description string or NULL.
+   *
+   * @see UserpointsTransaction::setDescription()
+   */
+  function getDescription() {
+    return $this->fields['description'];
+  }
+
+  /**
+   * The status of this transaction.
+   *
+   * There are helper functions available to check if the transaction has a
+   * specific status, e. g. UserpointsTransaction::isPending(). Considering
+   * using these if possible.
+   *
+   * @return
+   *   The status of this transaction (approved, declined, pending).
+   *
+   * @see UserpointsTransaction::setStatus()
+   * @see UserpointsTransaction::isPending()
+   * @see UserpointsTransaction::isApproved()
+   * @see UserpointsTransaction::isDeclined()
+   */
+  function getStatus() {
+    return $this->fields['status'];
+  }
+
+  /**
+   * The expiration date of this transaction, if defined.
+   *
+   * @return
+   *   The expiration date as timestamp or NULL.
+   *
+   * @see UserpointsTransaction::setExpiryDate()
+   */
+  function getExpiryDate() {
+    return $this->fields['expirydate'];
+  }
+
+  /**
+   * Returns if the transaction is expired or not.
+   *
+   * @return
+   *   TRUE if the transaction is expired, FALSE if not.
+   *
+   * @see UserpointsTransaction::setExpired()
+   */
+  function isExpired() {
+    return $this->fields['expired'];
+  }
+
+  /**
+   * Returns the UID of the user this transaction belongs to.
+   *
+   * @return
+   *   The uid of the user.
+   *
+   * @see UserpointsTransaction::setUid()
+   */
+  function getUid() {
+    return $this->fields['uid'];
+  }
+
+  /**
+   * Returns the user object this transaction belongs to.
+   *
+   * @return
+   *   loaded user object for the user this transaction belongs to.
+   *
+   * @see UserpointsTransaction::setUid()
+   * @see UserpointsTransaction::getUid()
+   */
+  function getUser() {
+    return user_load($this->fields['uid']);
+  }
+
+  /**
+   * Returns the uid of the user who approved this transaction.
+   *
+   * @return
+   *   The approver uid.
+   *
+   * @see UserpointsTransaction::setApproverUid()
+   */
+  function getApproverUid() {
+    return $this->fields['approver_uid'];
+  }
+
+  /**
+   * The loaded user object of the user who approved this transaction.
+   *
+   * @return
+   *   User object.
+   *
+   * @see UserpointsTransaction::setApproverUid()
+   * @see UserpointsTransaction::getApproverUid()
+   */
+  function getApprover() {
+    return user_load($this->fields['approver_uid']);
+  }
+
+  /**
+   * The amount of points of this transaction.
+   *
+   * @return
+   *   Points as an integer.
+   *
+   * @see UserpointsTransaction::setPoints()
+   */
+  function getPoints() {
+    return $this->fields['points'];
+  }
+
+  /**
+   * The timestamp of when this transaction was created.
+   *
+   * @return
+   *   Unix timestamp of the creation date.
+   *
+   * @see UserpointsTransaction::setTimestamp()
+   */
+  function getTimestamp() {
+    return $this->fields['time_stamp'];
+  }
+
+  /**
+   * The timestamp of when this transaction last changed.
+   *
+   * @return
+   *   Unix timestamp of the changed date.
+   */
+  function getChanged() {
+    return $this->fields['changed'];
+  }
+
+  /**
+   * Returns the parent transaction if there is any.
+   *
+   * @return UserpointsTransaction
+   *   A userpoints transaction or NULL.
+   *
+   * @see UserpointsTransaction::setParent()
+   */
+  function getParent() {
+    if (!empty($this->fields['parent_txn_id'])) {
+      return userpoints_transaction_load($this->fields['parent_txn_id']);
+    }
+  }
+
+  /**
+   * The category id (term id) this transaction belongs to.
+   *
+   * Use UserpointsTransaction::getCategory() to get the name of the category.
+   *
+   * @return
+   *   Term Id of this transaction.
+   *
+   * @see UserpointsTransaction::setTid()
+   * @see UserpointsTransaction::getCategory()
+   */
+  function getTid() {
+    return $this->fields['tid'];
+  }
+
+  /**
+   * The operation of this transaction.
+   *
+   * @return
+   *   The operation string of this transaction.
+   *
+   * @see UserpointsTransaction::setOperation()
+   */
+  function getOperation() {
+    return $this->fields['operation'];
+  }
+
+  /**
+   * The transaction id of this transaction.
+   *
+   * @return
+   *   The id of this transaction as an integer. NULL if this transaction has
+   *   not yet been saved.
+   */
+  function getTxnId() {
+    return $this->txn_id;
+  }
+
+  /**
+   * Check if a message about this transaction should be displayed.
+   *
+   * @return
+   *   TRUE if a message should be displayed, FALSE otherwise.
+   *
+   * @see UserpointsTransaction::setDisplay()
+   */
+  function getDisplay() {
+    return $this->display;
+  }
+
+  /**
+   * The category of this transaction.
+   *
+   * @return
+   *   The name of the category as a string. Name of th default category the
+   *   term of this category has been deleted.
+   *
+   * @see UserpointsTransaction::setTid()
+   * @see UserpointsTransaction::getTid()
+   */
+  function getCategory() {
+    // Load categories.
+    $categories = userpoints_get_categories();
+    return isset($categories[$this->getTid()]) ? $categories[$this->getTid()] : $categories[userpoints_get_default_tid()];
+  }
+
+  /**
+   * Mark this transaction as pending.
+   *
+   * @see UserpointsTransaction::setStatus
+   */
+  function pending() {
+    $this->setStatus(UserpointsTransaction::STATUS_PENDING);
+    return $this;
+  }
+
+  /**
+   * Mark this transaction as approved.
+   *
+   * @see UserpointsTransaction::setStatus
+   */
+  function approve() {
+    $this->setStatus(UserpointsTransaction::STATUS_APPROVED);
+    return $this;
+  }
+
+  /**
+   * Mark this transaction as declined.
+   *
+   * @see UserpointsTransaction::setStatus
+   */
+  function decline() {
+    $this->setStatus(UserpointsTransaction::STATUS_DECLINED);
+    return $this;
+  }
+
+  /**
+   * Check if this transaction is pending.
+   *
+   * @see UserpointsTransaction::getStatus
+   */
+  function isPending() {
+    return $this->getStatus() == UserpointsTransaction::STATUS_PENDING;
+  }
+
+  /**
+   * Check if this transaction is declined.
+   *
+   * @see UserpointsTransaction::getStatus
+   */
+  function isDeclined() {
+    return $this->getStatus() == UserpointsTransaction::STATUS_DECLINED;
+  }
+
+  /**
+   * Check if this transaction is approved.
+   *
+   * @see UserpointsTransaction::getStatus
+   */
+  function isApproved() {
+    return $this->getStatus() == UserpointsTransaction::STATUS_APPROVED;
+  }
+
+  /**
+   * Save or update this transaction.
+   *
+   * It is not permitted to update a approved or denied transaction except
+   * marking it as expird. Any attemt to change a property of such a transaction
+   * will result in an immediate exception.
+   */
+  function save() {
+    // Prevent saving when any of the required properties are missing.
+    if (!$this->getPoints() || !$this->getUid() || !$this->getOperation()) {
+      $this->abort();
+      throw new UserpointsTransactionIncompleteException();
+    }
+
+    // Call the before hook to allow modules to change and deny this.
+    module_invoke_all('userpoints_transaction_before', $this);
+
+    // Abort if the transaction has been denied.
+    if ($this->isDenied()) {
+      $this->abort();
+      throw new UserpointsTransactionDeniedException($this->getDenyReasons());
+    }
+
+    if (empty($this->txn_id)) {
+      // This is a new transaction, save.
+      $this->txn_id = db_insert('userpoints_txn')
+          ->fields($this->fields)
+          ->execute();
+    }
+    else {
+      // Existing transaction, update.
+      db_update('userpoints_txn')
+        ->condition('txn_id', $this->txn_id)
+        ->fields($this->fields)
+        ->execute();
+    }
+
+    // Update totals if the transaction is approved and not expired.
+    if ($this->isApproved() && !$this->isExpired()) {
+      $this->updateTotals();
+    }
+
+    // Call the after hook so that modules can react on this.
+    module_invoke_all('userpoints_transaction_after', $this);
+
+    // Display a message unless disabled or no message exists.
+    if ($this->getDisplay() && $message = $this->getMessage()) {
+      drupal_set_message($message);
+    }
+
+    // Reset original status to current one.
+    $this->orig_status = $this->getStatus();
+    return $this;
+  }
+
+  /**
+   * Update the total aggregations of the corresponding user.
+   */
+  protected function updateTotals() {
+    // Update this category.
+    $this->updateTotalsCategory($this->getTid());
+    // Update the total over all categories.
+    $this->updateTotalsCategory('all');
+  }
+
+  /**
+   * Update the totals of a specific category.
+   *
+   * Updating the total of all categories is supported by using 'all' for the
+   * $tid.
+   */
+  protected function updateTotalsCategory($tid) {
+    $table = 'userpoints';
+    if ($tid === 'all') {
+      // Use a different table for the overall total.
+      $table = 'userpoints_total';
+    }
+
+    // Always update the time stamp and the total points.
+    $total = array(
+      'last_update' => REQUEST_TIME,
+      'points' => $this->getPoints() + userpoints_get_current_points($this->getUid(), $tid),
+    );
+    // Update the total max points if necessary.
+    $max_points_total = userpoints_get_max_points($this->getUid(), $tid);
+    if ($total['points'] > $max_points_total) {
+      $total['max_points'] = $total['points'];
+    }
+
+    // The keys for the merge query. The tid is only added when not 'all'.
+    $keys = array(
+      'uid' => $this->getUid(),
+    );
+    if ($tid !== 'all') {
+      $keys['tid'] = $tid;
+    }
+
+    // Save the updates.
+    db_merge($table)
+      ->key($keys)
+      ->fields($total)
+      ->execute();
+  }
+
+  /**
+   * Checks if a change is allowed.
+   *
+   * @param $only_new
+   *   If TRUE, only allows changes if this transaction is new. Defaults to
+   *   FALSE.
+   *
+   * @throws UserpointsChangeException
+   */
+  protected function checkChange($only_new = FALSE) {
+    if ($this->isReadOnly($only_new)) {
+      $this->abort();
+      throw new UserpointsChangeException(t('This transaction is saved and approved or declined and can not be changed.'));
+    }
+  }
+
+  /**
+   * Checks if a change is allowed.
+   *
+   * Once a transaction is saved and either approved or declined, no alterations
+   * of the data is allowed except marking it as expired.
+   *
+   * @param $only_new
+   *   If TRUE, only allows changes if this transaction is new. Defaults to
+   *   FALSE.
+   *
+   * @return
+   *   TRUE if changes are allowed, FALSE if not.
+   */
+  function isReadOnly($only_new = FALSE) {
+    if (!empty($this->txn_id)) {
+      if ($only_new) {
+        return TRUE;
+      }
+      if ($this->orig_status !== NULL && $this->orig_status <> UserpointsTransaction::STATUS_PENDING) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Deny this transaction from being saved.
+   *
+   * This is typically called in hook_userpoints_transaction_before().
+   *
+   * @see UserpointsTransaction::isDenied()
+   * @see UserpointsTransaction::getDenyReasons()
+   */
+  function deny($reason) {
+    $this->denied_reasons[] = $reason;
+  }
+
+  /**
+   * Check if this transaction is denied.
+   *
+   * A transaction is denied if there are any deny reasons.
+   *
+   * @see UserpointsTransaction::deny().
+   * @see UserpointsTransaction::getDenyReasons()
+   */
+  function isDenied() {
+    return !empty($this->denied_reasons);
+  }
+
+  /**
+   * Returns the deny reasons for this transaction.
+   *
+   * @return
+   *   An array with the reasons why this transaction was denied.
+   *
+   * @see UserpointsTransaction::deny()
+   * @see UserpointsTransaction::isDenied()
+   * @see UserpointsTransaction::getDenyReasons()
+   */
+  function getDenyReasons() {
+    return $this->denied_reasons;
+  }
+
+  /**
+   * Override the generated default message of this transaction.
+   *
+   * @param $message
+   *   The message that should be displayed if configured to do so.
+   *
+   * @return UserpointsTransaction
+   *
+   * @see UserpointsTransaction::getMessage()
+   * @see UserpointsTransaction::setDisplay()
+   */
+  function setMessage($message) {
+    $this->message = $message;
+    return $this;
+  }
+
+  /**
+   * A message that can be displayed to the current user.
+   *
+   * If set, the message defined by UserpointsTransaction::setMessage() is used.
+   * Otherwise, a message is displayed that takes into account the points amount
+   * (negative or positive), the category, the status and if the transaction is
+   * for the currently logged in user or not.
+   *
+   * @return
+   *   A message string that describes this transaction to the currently logged
+   *   in user. Can be empty if not automated message could have been generated.
+   *
+   * @see UserpointsTransaction::setMessage().
+   */
+  function getMessage() {
+    global $user;
+
+    // If set, use the overriden message.
+    if (!empty($this->message)) {
+      return $this->message;
+    }
+    // Prepare arguments. They are the same for all string combinations.
+    $categories = userpoints_get_categories();
+    $arguments = array_merge(userpoints_translation(), array(
+        '!username' => theme('username', array('account' => $this->getUser())),
+        '%total' => userpoints_get_current_points($this->getUid(), $this->getTid()),
+        '%category' => $this->getCategory(),
+      ));
+
+    $view_own_points = user_access('view own userpoints') || user_access('view userpoints') || user_access('administer userpoints');
+    $view_all_points = user_access('view userpoints') || user_access('administer userpoints');
+
+    $message = NULL;
+    if ($this->isDeclined()) {
+      // Points have been declined.
+      if ($this->getUid() == $user->uid && $view_own_points) {
+        $message = format_plural($this->getPoints(), 'You did not receive approval for @count !point in the %category category.', 'You did not receive approval for @count !points in the %category category.', $arguments);
+      }
+      elseif ($view_all_points) {
+        $message = format_plural($this->getPoints(), '!username did not receive approval for @count !point in the %category category.', '!username did not receive approval for @count !points in the %category category.', $arguments);
+      }
+    }
+    elseif ($this->getPoints() < 0) {
+      if ($this->isPending()) {
+        if ($this->getUid() == $user->uid && $view_own_points) {
+          // Directly address the user if he is loosing points.
+          $message = format_plural(abs($this->getPoints()), 'You just had a !point deducted, pending administrator approval.', 'You just had @count !points deducted, pending administrator approval.', $arguments);
+        }
+        elseif ($view_all_points) {
+          // Only display message about other users if user has permission to view userpoints.
+          $message = format_plural(abs($this->getPoints()), '!username just had a !point deducted, pending administrator approval.', '!username just had @count !points deducted, pending administrator approval.', $arguments);
+        }
+      }
+      else {
+        if ($this->getUid() == $user->uid && $view_own_points) {
+          $message = format_plural(abs($this->getPoints()), 'You just had a !point deducted and now have %total !points in the %category category.', 'You just had @count !points deducted and now have %total !points in the %category category.', $arguments);
+        }
+        elseif ($view_all_points) {
+          $message = format_plural(abs($this->getPoints()), '!username just had a !point deducted and now has %total !points in the %category category.', '!username just had @count !points deducted and now has %total !points in the %category category.', $arguments);
+        }
+      }
+    }
+    else {
+      if ($this->isPending()) {
+        if ($this->getUid() == $user->uid && $view_own_points) {
+          // Directly address the user if he is loosing points.
+          $message = format_plural(abs($this->getPoints()), 'You just earned a !point, pending administrator approval.', 'You just earned @count !points, pending administrator approval.', $arguments);
+        }
+        elseif ($view_all_points) {
+          // Only display message about other users if user has permission to view userpoints.
+          $message = format_plural(abs($this->getPoints()), '!username just earned a !point, pending administrator approval.', '!username just earned @count !points, pending administrator approval.', $arguments);
+        }
+      }
+      else {
+        if ($this->getUid() == $user->uid && $view_own_points) {
+          $message = format_plural(abs($this->getPoints()), 'You just earned a !point and now have %total !points in the %category category.', 'You just earned @count !points and now have %total !points in the %category category.', $arguments);
+        }
+        elseif ($view_all_points) {
+          $message = format_plural(abs($this->getPoints()), '!username just earned a !point and now has %total !points in the %category category.', '!username just earned @count !points and now has %total !points in the %category category.', $arguments);
+        }
+      }
+    }
+    return $message;
+  }
+
+  /**
+   * Returns additional information about the operation of this transaction.
+   *
+   * @return
+   *   Information about this operation as an array.
+   *
+   * @see userpoints_get_info()
+   */
+  function getOperationInfo() {
+    return userpoints_get_info($this->getOperation());
+  }
+
+  /**
+   * Returns a descriptive reason for this transaction.
+   *
+   * The following resources are considered, in this order:
+   *
+   *  * description key in the information array for that operation.
+   *  * description of the transaction.
+   *  * name of the operation.
+   *
+   * @param $options
+   *   Array of options:
+   *   - link: If FALSE, no link is generated to the linked entity even if there
+   *     were one. Defaults to TRUE.
+   *   - truncate: Define if the reason should be truncated. Defaults to TRUE.
+   *   - skip_description: Allows to skip the eventually existing custom
+   *     description a transaction has and always use the generated description.
+   *
+   * @return
+   *   The reason for that transaction, linked to the referenced entity if
+   *   available.
+   */
+  function getReason(array $options = array()) {
+
+    // Default options.
+    $options += array(
+      'link' => TRUE,
+      'truncate' => TRUE,
+    );
+
+    $safe = FALSE;
+    // Check transaction description first to allow custom overrides.
+    if (empty($options['skip_description']) && $description = $this->getDescription()) {
+      $reason = $description;
+    }
+    else {
+      $info = $this->getOperationInfo();
+      // Check if there is a valid description callback defined for this
+      // operation.
+      if (!empty($info['description callback']) && function_exists($info['description callback'])) {
+        $reason = $info['description callback']($this, $this->getEntity());
+        $safe = TRUE;
+      }
+      // Try static description key.
+      elseif (!empty($info['description'])) {
+        $reason = $info['description'];
+        $safe = TRUE;
+      }
+    }
+    // Fallback to the operation name if there is no source.
+    if (empty($reason)) {
+      $reason = $this->getOperation();
+    }
+
+    // Truncate description.
+    $attributes = array();
+    $stripped_reason = strip_tags($reason);
+    if ($options['truncate'] && drupal_strlen($stripped_reason) > variable_get('userpoints_truncate', 30) + 3) {
+      // The title attribute will be check_plain()'d again drupal_attributes(),
+      // avoid double escaping.
+      $attributes['title'] = html_entity_decode($stripped_reason, ENT_QUOTES);
+      $reason = truncate_utf8($stripped_reason, variable_get('userpoints_truncate', 30), FALSE, TRUE);
+    }
+
+    // Link to the referenced entity, if available.
+    if ($this->getEntity() && $options['link']) {
+      $uri = entity_uri($this->getEntityType(), $this->getEntity());
+      if ($uri) {
+        $reason = l($reason, $uri['path'], $uri['options'] + array('html' => $safe, 'attributes' => $attributes));
+      }
+    }
+    if ((!$this->getEntity() || empty($uri)) && !$safe) {
+      // Escape possible user provided reason.
+      $reason = check_plain($reason);
+    }
+    return $reason;
+  }
+
+  /**
+   * Returns a list of operations as links.
+   *
+   * @param $show_view
+   *   FALSE if the view link should not be displayed. Defaults to TRUE.
+   *
+   * @return
+   *   A string with operation links.
+   */
+  function getActions($show_view = TRUE) {
+    $actions = array();
+    $url_options = array('query' => drupal_get_destination());
+
+    $uri = userpoints_transaction_uri_callback($this, TRUE);
+    $url_prefix = $uri['path'];
+
+    if ($show_view && userpoints_access_view_transaction($this)) {
+      $actions[] = l('view', $url_prefix . '/view');
+    }
+    if (userpoints_admin_access('edit')) {
+      $actions[] = l('edit', $url_prefix . '/edit', $url_options);
+    }
+    if (userpoints_admin_access('moderate') && $this->isPending()) {
+      $actions[] = l('approve', $url_prefix . '/approve', $url_options);
+      $actions[] = l('decline', $url_prefix . '/decline', $url_options);
+    }
+    return implode(' ', $actions);
+  }
+
+  /**
+   * Returns a single row for a transaction listing.
+   *
+   * @param $settings
+   *   Array with settings about which column shall be displayed. All settings
+   *   default to TRUE.
+   *   - show_category, show category column.
+   *   - show_user, show user column.
+   *   - show_status, show status column.
+   *
+   * @return
+   *   A table row array for use with theme_table().
+   */
+  function getTableRow($settings = array()) {
+    $settings += array(
+      'show_user' => TRUE,
+      'show_status' => TRUE,
+    );
+
+    $stati = userpoints_txn_status();
+    $css_stati = array(
+      UserpointsTransaction::STATUS_APPROVED => 'approved',
+      UserpointsTransaction::STATUS_DECLINED => 'declined',
+      UserpointsTransaction::STATUS_PENDING => 'pending',
+    );
+    $row = array('class' => array(
+        'userpoints-transaction-row-status-' . $css_stati[$this->getStatus()],
+        'userpoints-transaction-row-category-' . $this->getTid()),
+    );
+    if ($settings['show_user']) {
+      $row['data'][] = array(
+        'data' => theme('username', array('account' => $this->getUser())),
+        'class' => array('userpoints-transactions-field-user'),
+      );
+    }
+    $row['data'][] = array(
+      'data' => $this->getPoints(),
+      'class' => array('userpoints-transactions-field-points', 'userpoints-transaction-points-' . ($this->getPoints() > 0 ? 'positive' : 'negative')),
+    );
+
+    $categories = userpoints_get_categories();
+    if (count($categories) > 1) {
+      $row['data'][] = array(
+        'data' => $this->getCategory(),
+        'class' => array('userpoints-transactions-field-category'),
+      );
+    }
+    $row['data'][] = array(
+      'data' => format_date($this->getTimestamp(), 'small'),
+      'class' => array('userpoints-transactions-field-timestamp'),
+    );
+    $row['data'][] = array(
+      'data' => $this->getReason(),
+      'class' => array('userpoints-transactions-field-reason'),
+    );
+    if ($settings['show_status']) {
+      $row['data'][] = array(
+        'data' => $stati[$this->getStatus()],
+        'class' => array('userpoints-transactions-field-status'),
+      );
+    }
+    $row['data'][] = array(
+      'data' => $this->getActions(),
+      'class' => array('userpoints-transactions-field-actions'),
+    );
+    return $row;
+  }
+
+  /**
+   * Automatically called if this object is goes out of scope and is destructed.
+   */
+  function __destruct() {
+    // Automatically save new transactions to improve DX.
+    if (!$this->getTxnId() && !$this->isAborted()) {
+      $this->save();
+    }
+  }
+
+  /**
+   * Magic function to allow access to property by name.
+   */
+  function __get($name) {
+
+    // Compatibility with dpm().
+    if (strpos($name, 'krumo') !== FALSE) {
+      if (!isset($this->$name)) {
+        $this->$name = NULL;
+      }
+      return $this->$name;
+    }
+
+    $method = 'get' . str_replace('_', '', $name);
+    if (method_exists($this, $method)) {
+      return $this->$method();
+    }
+    else {
+      throw new UserpointsInvalidPropertyException($name);
+    }
+  }
+
+  /**
+   * Magic function to allow access to property by name.
+   */
+  function __set($name, $value) {
+
+    $method = 'get' . str_replace('_', '', $name);
+    if (method_exists($this, $method)) {
+      return $this->$method($value);
+    }
+    else {
+      // Some modules, e.g rdf.module and devel.module try to set custom
+      // properties on entites. Do not prevent this.
+      return $this->$name = $value;
+    }
+  }
+}
+
+/**
+ * This exception is thrown when a pr{ "rules_userpoints_transaction_after_test_rule" : {
+        "LABEL" : "Userpoints Transaction After Test rule",
+        "PLUGIN" : "reaction rule",
+        "REQUIRES" : [ "rules", "userpoints_rules" ],
+        "ON" : [ "userpoints_event_points_awarded_after" ],
+        "IF" : [
+          { "data_is" : {
+              "data" : [ "userpoints-transaction:operation" ],
+              "value" : "userpoints_rules_trigger_after_rule"
+            }
+          }
+        ],
+        "DO" : [
+          { "data_set" : { "data" : [ "userpoints-transaction:status" ], "value" : "1" } },
+          { "data_set" : {
+              "data" : [ "userpoints-transaction:reference" ],
+              "value" : "Transaction was set to pending through rules."
+            }
+          }
+        ]
+      }
+    }operty is changed after a saved transaction
+ * has been approved or declined.
+ */
+class UserpointsChangeException extends Exception {
+
+}
+
+/**
+ * This exception is thrown when trying to access an unknown property through
+ * the magic UserpointsTransaction::__get() method.
+ */
+class UserpointsInvalidPropertyException extends Exception {
+  function __construct($name, $code = NULL, $previous = NULL) {
+    parent::__construct(t('Userpoints transaction does not have a @property property.', array('@property' => $name)), $code, $previous);
+  }
+}
+
+/**
+ * Thrown when trying to set a property to an invalid value.
+ */
+class UserpointsInvalidArgumentException extends Exception {
+
+}
+
+/**
+ * Thrown when trying to save a transaction without points, uid or operation.
+ */
+class UserpointsTransactionIncompleteException extends Exception {
+
+}
diff --git a/userpoints_rules.info b/userpoints_rules.info
index 09da8c9..d656d5b 100644
--- a/userpoints_rules.info
+++ b/userpoints_rules.info
@@ -6,4 +6,5 @@ dependencies[] = rules
 package = Userpoints
 core = 7.x
 files[]=userpoints_rules.rules.inc
+files[]=userpoints_rules.test
 
diff --git a/userpoints_rules.module b/userpoints_rules.module
index 49a6e69..292bd0c 100644
--- a/userpoints_rules.module
+++ b/userpoints_rules.module
@@ -1,6 +1,5 @@
 <?php
 
-
 /**
  * @file
  * Rules integration for the user points module.
@@ -9,54 +8,13 @@
 /**
  * Implements hook_userpoints().
  */
-function userpoints_rules_userpoints($op, &$params = array()) {
-  // Transactions without points are not passed to rules for now.
-  if (!is_array($params) || !isset($params['points'])) {
-    return;
-  }
-
-
-  $event = '';
-  switch ($op) {
-    case 'points after':
-      $event = 'userpoints_event_points_awarded_after';
-      break;
-
-    case 'points before':
-      $event = 'userpoints_event_points_awarded_before';
-      break;
-  }
-
-  try {
-
-    if (!empty($event)) {
-      // Convert to an object.
-      $userpoints_transaction = (object) $params;
+function userpoints_rules_userpoints_transaction_before($userpoints_transaction) {
+  rules_invoke_event('userpoints_event_points_awarded_before', $userpoints_transaction);
+}
 
-      // Provide uid as wrapped user entity.
-      $userpoints_transaction->user = entity_metadata_wrapper('user', $userpoints_transaction->uid);
-
-      $userpoints_transaction->entity = NULL;
-      if (!empty($userpoints_transaction->entity_type) && !empty($userpoints_transaction->entity_id)) {
-        // Make entity available as lazy loading wrapper
-        $userpoints_transaction->entity = entity_metadata_wrapper($userpoints_transaction->entity_type, $userpoints_transaction->entity_id);
-      }
-      // Invoke rules event.
-      rules_invoke_event($event, $userpoints_transaction);
-
-      // Convert back to keep any changes to the properties.
-      $params = (array) $userpoints_transaction;
-      $params['uid'] = $userpoints_transaction->user->getIdentifier();
-      if ($userpoints_transaction->entity) {
-        $params['entity_type'] = $userpoints_transaction->entity->type();
-        $params['entity_id'] = $userpoints_transaction->entity->getIdentifier();
-      }
-      unset($params['entity']);
-    }
-  } catch (EntityMetadataWrapperException $e) {
-    // The referenced entity does not exist.
-    // @todo: Find a way to handle this more gracefully.
-  }
-  unset($params['entity']);
-  unset($params['user']);
+/**
+ * Implements hook_userpoints().
+ */
+function userpoints_rules_userpoints_transaction_after($userpoints_transaction) {
+  rules_invoke_event('userpoints_event_points_awarded_after', $userpoints_transaction);
 }
\ No newline at end of file
diff --git a/userpoints_rules.rules.inc b/userpoints_rules.rules.inc
index bf7362b..ccf6f73 100644
--- a/userpoints_rules.rules.inc
+++ b/userpoints_rules.rules.inc
@@ -165,28 +165,31 @@ function userpoints_action_grant_points($params) {
   $state = $params['state'];
   $entity = $state->currentArguments['entity'];
 
-  // Map $moderate value to the actual value used by the API.
-  $moderate_mapping = array(
-    'default' => NULL,
-    'approved' => FALSE,
-    'moderated' => TRUE,
-  );
-  $params = array(
+  $transaction = userpoints_grant_points($params['operation'], $params['points'])
     // User id might be a int or a EntityValueWrapper.
-    'uid' => is_object($params['user']->uid) ? $params['user']->getIdentifier() : $params['user']->uid,
-    'entity_type' => $entity ? $entity->type() : NULL,
-    'entity_id' => $entity ? $entity->getIdentifier() : NULL,
-    'moderate' => $moderate_mapping[$params['moderate']],
-    // Rules defaults to FALSE if the date format can not be parsed.
-    // Use NULL instead since FALSE means no expiration.
-    'expirydate' => $params['expirydate'] ? $params['expirydate'] : NULL,
-  ) + $params;
+    ->setUid(is_object($params['user']->uid) ? $params['user']->getIdentifier() : $params['user']->uid)
+    ->setTid($params['tid'])
+    ->setDescription($params['description'])
+    ->setReference($params['reference'])
+    ->setDisplay($params['display']);
+
+  if ($entity) {
+    $transaction->setEntity($entity->type(), $entity->getIdentifier());
+  }
+
+  if ($params['moderate'] != 'default') {
+    if ($params['moderate'] == 'approved') {
+      $transaction->approve();
+    }
+    else {
+      $transaction->pending();
+    }
+  }
 
-  unset($params['state']);
-  unset($params['user']);
-  unset($params['entity']);
-  unset($params['settings']);
-  userpoints_userpointsapi($params);
+  if ($params['expirydate']) {
+    $transaction->setExpiryDate($params['expirydate']);
+  }
+  $transaction->save();
 }
 
 function userpoints_action_grant_points_form_alter(&$form, &$form_state) {
diff --git a/userpoints_rules.test b/userpoints_rules.test
new file mode 100644
index 0000000..0da26dd
--- /dev/null
+++ b/userpoints_rules.test
@@ -0,0 +1,186 @@
+<?php
+
+/**
+ * @file
+ * Tests for Userpoints Services integration.
+ */
+
+class UserpointsRulesTestCase extends DrupalWebTestCase {
+
+  /**
+   * Implements getInfo().
+   */
+  public static function getInfo() {
+    return array(
+      'name' => t('Rules integration'),
+      'description' => t('Tests the rules events and actions.'),
+      'group' => t('Userpoints'),
+      'dependencies' => array('rules'),
+    );
+  }
+
+  public function setUp() {
+    parent::setUp(array('userpoints', 'entity', 'rules', 'userpoints_rules'));
+
+  }
+  
+  /**
+   * Test the rules actions exposed by this module.
+   */
+  public function testActions() {
+    $user = $this->drupalCreateUser(array('view userpoints'));
+    
+    $this->createActionRules();
+    
+    $this->drupalLogin($user);
+    $this->drupalGet('myuserpoints');
+    
+    $this->clickLink(t('view'));
+    
+    // Get the transaction id and load the transaction.
+    preg_match('|myuserpoints\/transaction\/([0-9]+)\/view|', $this->getURL(), $matches);
+    $txn_id = $matches[1];
+    $transaction = userpoints_transaction_load($txn_id);
+    
+    // Assert a few properties.
+    $this->assertEqual(10, $transaction->getPoints());
+    $this->assertTrue($transaction->isApproved());
+    $this->assertTrue($transaction->getExpiryDate() > REQUEST_TIME);
+    $this->assertEqual('userpoints_rules_login_test', $transaction->getOperation());
+    
+    // Verify that the reason can be clicked and points to the user profile.
+    $this->clickLink(t('Granted points for logging in.'));
+    $this->assertEqual(url('user/' . $user->uid, array('absolute' => TRUE)), $this->getUrl());
+    
+    $this->drupalLogout();
+  }
+
+  /**
+   * Test the before and after events and the setter and getter callbacks.
+   */
+  public function testEvents() {
+    $user = $this->drupalCreateUser(array('view userpoints'));
+    
+    $this->createEventRules();
+
+    $transaction = userpoints_grant_points('userpoints_rules_trigger_before_rule', 10, $user->uid)
+      ->save();
+    
+    // Verify that the changes defined in the rules have been done.
+    $this->assertTrue($transaction->isDeclined(), t('Transaction has been marked as declined.'));
+    $this->assertEqual('Transaction was declined through rules.', $transaction->getDescription(), t('Transaction description has been set.'));
+    
+    // Verify that the changes have been persisted in the database.
+    $transaction = userpoints_transaction_load($transaction->getTxnId());
+    $this->assertTrue($transaction->isDeclined(), t('Transaction has been marked as declined.'));
+    $this->assertEqual('Transaction was declined through rules.', $transaction->getDescription(), t('Transaction description has been set.'));
+    
+    $transaction = userpoints_grant_points('userpoints_rules_trigger_after_rule', 15, $user->uid)
+      ->pending()
+      ->save();
+
+    // Verify that the changes defined in the rules have been done.
+    $this->assertTrue($transaction->isApproved(), t('Transaction has been marked as pending.'));
+    $this->assertEqual('Transaction was set to pending through rules.', $transaction->getReference(), t('Transaction reference has been set.'));
+    
+    // Verify that the changes have not be written to the database as this is
+    // the after event.
+    $transaction = userpoints_transaction_load($transaction->getTxnId());
+    $this->assertFalse($transaction->isApproved(), t('Transaction has not been marked as pending.'));
+    $this->assertEqual('', $transaction->getReference(), t('Transaction reference has not been saved.'));
+    
+  }
+  
+  /**
+   * Set up the rules required for the tests.
+   */
+  protected function createEventRules() {
+        $before_rule = '{ "rules_userpoints_transaction_before_test_rule" : {
+        "LABEL" : "Userpoints Transaction Before Test rule",
+        "PLUGIN" : "reaction rule",
+        "REQUIRES" : [ "rules", "userpoints_rules" ],
+        "ON" : [ "userpoints_event_points_awarded_before" ],
+        "IF" : [
+          { "data_is" : {
+              "data" : [ "userpoints-transaction:operation" ],
+              "value" : "userpoints_rules_trigger_before_rule"
+            }
+          }
+        ],
+        "DO" : [
+          { "data_set" : { "data" : [ "userpoints-transaction:status" ], "value" : "2" } },
+          { "data_set" : {
+              "data" : [ "userpoints-transaction:description" ],
+              "value" : "Transaction was declined through rules."
+            }
+          }
+        ]
+      }
+    }';
+
+    $after_rule = '{ "rules_userpoints_transaction_after_test_rule" : {
+        "LABEL" : "Userpoints Transaction After Test rule",
+        "PLUGIN" : "reaction rule",
+        "REQUIRES" : [ "rules", "userpoints_rules" ],
+        "ON" : [ "userpoints_event_points_awarded_after" ],
+        "IF" : [
+          { "data_is" : {
+              "data" : [ "userpoints-transaction:operation" ],
+              "value" : "userpoints_rules_trigger_after_rule"
+            }
+          }
+        ],
+        "DO" : [
+          { "data_set" : { "data" : [ "userpoints-transaction:status" ], "value" : "0" } },
+          { "data_set" : {
+              "data" : [ "userpoints-transaction:reference" ],
+              "value" : "Transaction was set to pending through rules."
+            }
+          }
+        ]
+      }
+    }';
+
+    // Save the rules.
+    $rule = rules_import($before_rule);
+    $rule->save();
+    $rule = rules_import($after_rule);
+    $rule->save();
+    
+    drupal_static_reset('rules_get_cache');
+    cache_clear_all('*', 'cache_rules', TRUE);
+  }
+  
+  function createActionRules() {
+    $grant_action = '{ "rules_userpoints_rules_grant_points_test" : {
+        "LABEL" : "Userpoints Rules grant points test",
+        "PLUGIN" : "reaction rule",
+        "REQUIRES" : [ "rules", "userpoints_rules" ],
+        "ON" : [ "user_login" ],
+        "DO" : [
+          { "userpoints_action_grant_points" : {
+              "user" : [ "account" ],
+              "points" : "10",
+              "tid" : "0",
+              "entity" : [ "account" ],
+              "description" : "Granted points for logging in.",
+              "operation" : "userpoints_rules_login_test",
+              "reference" : "Userpoints Rules Test",
+              "display" : 1,
+              "moderate" : "approved",
+              "expirydate" : "+1 year"
+            }
+          }
+        ]
+      }
+    }';
+    
+    // Save the rules.
+    $rule = rules_import($grant_action);
+    $rule->save();
+    
+    drupal_static_reset('rules_get_cache');
+    cache_clear_all('*', 'cache_rules', TRUE);
+  }
+
+}
\ No newline at end of file
diff --git a/userpoints_service.inc b/userpoints_service.inc
index 3024114..23c6032 100644
--- a/userpoints_service.inc
+++ b/userpoints_service.inc
@@ -79,23 +79,25 @@ function userpoints_service_add($uid, $points, $tid, $operation, $description, $
     return services_error(t('Points parameter must be a negative or positive number.'));
   }
 
-  $params = array(
-    'uid' => $uid,
-    'points' => $points,
-    'tid' => $tid,
-    'operation' => $operation,
-    'description' => $description,
-    'entity_type' => $entity_type,
-    'entity_id' => $entity_id,
-  );
-  $result = userpoints_userpointsapi($params);
+  if (!$operation) {
+    return services_error(t('Operation parameter is required'));
+  }
+
+  $transaction = userpoints_grant_points($operation, $points, $uid)
+    ->setDescription($description)
+    ->setEntity($entity_type, $entity_id);
+
+  if ($tid !== NULL) {
+   $transaction->setTid($tid);
+  }
+  $transaction->save();
 
-  if (!$result['status']) {
-    return services_error(t('Adding points failed: @reason' ,array('@reason' => $result['reason'])));
+  if (!$transaction->getTxnId()) {
+    return services_error(t('Adding points failed.'));
   }
 
   return (object) array(
-    'id' => $result['transaction']['txn_id'],
-    'uri' => services_resource_uri(array('userpoints_transaction', $result['transaction']['txn_id'])),
+    'id' => $transaction->getTxnId(),
+    'uri' => services_resource_uri(array('userpoints_transaction', $transaction->getTxnId())),
   );
 }
\ No newline at end of file
diff --git a/userpoints_service.test b/userpoints_service.test
index 830f95a..ea612a6 100644
--- a/userpoints_service.test
+++ b/userpoints_service.test
@@ -77,6 +77,7 @@ class UserpointsServiceTestCase extends ServicesWebTestCase {
       $params = array(
         'uid' => $normal_user->uid,
         'points' => $points,
+        'operation' => 'services_test',
       );
       $this->servicesPost($this->endpoint->path . '/userpoints/add', $params);
       $total += $points;
@@ -90,6 +91,7 @@ class UserpointsServiceTestCase extends ServicesWebTestCase {
     $params = array(
       'uid' => $this->privilegedUser->uid,
       'points' => $points,
+      'operation' => 'services_test',
     );
     $this->servicesPost($this->endpoint->path . '/userpoints/add', $params);
 
