chezbetty/datalayer.py
import functools
from .models.model import *
from .models import event
from .models import transaction
from .models import account
from .models.pool import Pool
from .models.user import User
from .models import request
from .models import receipt
from .models.item import Item
from .models.box import Box
from .models import box_item
from .models import item_vendor
from .models import box_vendor
from .models import ephemeron
from .utility import notify_pool_out_of_credit
from .utility import notify_new_top_wall_of_shame
import math
# The amount we charge on top of what we pay
global wholesale_markup
wholesale_markup = Decimal(1.20)
# Give normal users an additional discount for having a large positive balance
global good_standing_discount
good_standing_discount = Decimal(0.05)
# Give volunteers an additional 5% discount on top of good standing
global good_standing_volunteer_discount
good_standing_volunteer_discount = good_standing_discount + Decimal(0.05)
# Give managers an additional 10% discount on top of good standing
global good_standing_manager_discount
good_standing_manager_discount = good_standing_discount + Decimal(0.10)
# Give administrators wholesale pricing
global admin_discount
admin_discount = Decimal(wholesale_markup - 1)
def top_debtor_wrapper(fn):
'''Wrapper function for transactions that watches for a new top debtor.
Should wrap any function that creates a purchase or deposit transaction.
Can't put this inside the Transaction class b/c the add/flush operations are
at a higher level.'''
@functools.wraps(fn)
def wrapper(*args, **kwargs):
# Record top debtor before new transaction
old_top_debtor = DBSession.query(User).order_by(User.balance).limit(1).one()
# Execute transaction. Txn function should call add() and flush()
ret = fn(*args, **kwargs)
# Check whether the top debtor has changed
new_top_debtor = DBSession.query(User).order_by(User.balance).limit(1).one()
print(old_top_debtor, new_top_debtor)
if new_top_debtor != old_top_debtor:
notify_new_top_wall_of_shame(new_top_debtor)
return ret
return wrapper
def can_undo_event(e):
if e.type != 'deposit' and e.type != 'purchase' and e.type != 'restock' \
and e.type != 'inventory' and e.type != 'emptycashbox' \
and e.type != 'emptysafe' \
and e.type != 'donation' and e.type != 'withdrawal' \
and e.type != 'reimbursement' \
and e.type != 'reconcile':
return False
if e.deleted:
return False
return True
# Call this to remove an event from chez betty.
def undo_event(e, user):
assert(can_undo_event(e))
line_items = {}
for t in e.transactions:
if t.to_account_virt:
t.to_account_virt.balance -= t.amount
if t.fr_account_virt:
t.fr_account_virt.balance += t.amount
if t.to_account_cash:
t.to_account_cash.balance -= t.amount
if t.fr_account_cash:
t.fr_account_cash.balance += t.amount
if t.type == 'purchase':
# Re-add the stock to the items that were purchased
for s in t.subtransactions:
line_items[s.item_id] = s.quantity
Item.from_id(s.item_id).in_stock += s.quantity
elif t.type == 'restock':
# Include the global cost so we can repopulate the box on the
# restock page.
line_items['global_cost'] = '{}'.format(t.amount_restock_cost)
# Record who we reimbursed this to
if t.to_account_cash:
line_items['reimbursee'] = t.to_account_cash.id
# Add all of the boxes and items to the return list
# Also remove the stock this restock added to each item
for i,s in zip(range(len(t.subtransactions)), t.subtransactions):
if s.type == 'restocklineitem':
item = Item.from_id(s.item_id)
line_items[i] = '{},{},{},{},{},{},{}'.format(
'item', s.item_id, s.quantity, s.wholesale,
s.coupon_amount, s.sales_tax, s.bottle_deposit)
item.in_stock -= s.quantity
elif s.type == 'restocklinebox':
line_items[i] = '{},{},{},{},{},{},{}'.format(
'box', s.box_id, s.quantity, s.wholesale,
s.coupon_amount, s.sales_tax, s.bottle_deposit)
for ss in s.subsubtransactions:
item = Item.from_id(ss.item_id)
item.in_stock -= ss.quantity
elif t.type == 'inventory':
# Change the stock of all the items by reversing the inventory count
for s in t.subtransactions:
quantity_diff = s.quantity - s.quantity_counted
s.item.in_stock += quantity_diff
line_items[s.item_id] = s.quantity_counted
elif t.type == 'donation':
line_items['donation'] = '{}'.format(t.amount)
# Don't need anything for emptycashbox. On those transactions no
# other tables are changed.
# Keep track of the old event ID.
line_items['old_event_id'] = e.id
# Just need to delete the event. All transactions will understand they
# were deleted as well.
e.delete(user)
return line_items
def can_delete_item(item):
if len(item.boxes) == 0 and\
len(item.vendors) == 0 and\
len(item.subtransactions) == 0 and\
len(item.subsubtransactions) == 0:
return True
return False
def delete_item(item):
boxitems = DBSession.query(box_item.BoxItem).filter(box_item.BoxItem.item_id==item.id).all()
for bi in boxitems:
DBSession.delete(bi)
itemvendors = DBSession.query(item_vendor.ItemVendor).filter(item_vendor.ItemVendor.item_id==item.id).all()
for iv in itemvendors:
DBSession.delete(iv)
DBSession.delete(item)
def can_delete_box(box):
if len(box.items) == 0 and\
len(box.vendors) == 0 and\
len(box.subtransactions) == 0:
return True
return False
def delete_box(box):
boxitems = DBSession.query(box_item.BoxItem).filter(box_item.BoxItem.box_id==box.id).all()
for bi in boxitems:
DBSession.delete(bi)
boxvendors = DBSession.query(box_vendor.BoxVendor).filter(box_vendor.BoxVendor.box_id==box.id).all()
for bv in boxvendors:
DBSession.delete(bv)
DBSession.delete(box)
# Call this to make a new item request
def new_request(user, request_text, vendor, vendor_url=None):
r = request.Request(user, request_text, vendor, vendor_url)
DBSession.add(r)
DBSession.flush()
return r
# Call this to let a user purchase items
@top_debtor_wrapper
def purchase(user, account, items):
assert(hasattr(user, "id"))
assert(len(items) > 0)
# Give discounts based on user type
discount = Decimal(0)
if user.balance > 20.0:
if user.role == "manager":
discount = good_standing_manager_discount
elif user.role == "volunteer":
discount = good_standing_volunteer_discount
else:
discount = good_standing_discount
# administrators always get the discount
if user.role == "administrator":
discount = admin_discount
# Need to calculate a total
amount = Decimal(0)
for item, quantity in items.items():
amount += Decimal(item.price * quantity)
intermediate = amount - (amount * discount).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
# Calculate a potential wall of shame fee
fee = None
fee_amount = Decimal(0)
result = user.balance - intermediate
fee_percent = Decimal(0)
# Assign a fine for being in debt; administrators get a pass
if result <= Decimal('-5') and user.role != "administrator":
remainder = (user.balance - intermediate) * Decimal('-1')
offset = user.balance * Decimal('-1')
if user.balance > Decimal('-5'):
offset = Decimal('5')
fee_percent = math.floor(offset / Decimal('5')) * Decimal('5')
while True:
extra = remainder - offset
if remainder < fee_percent + Decimal('5'):
fee_amount += ((fee_percent * Decimal('0.01')) * extra)
break
else:
fee_amount += ((fee_percent * Decimal('0.01')) * (fee_percent + Decimal('5') - offset))
fee_percent += Decimal('5')
offset = fee_percent
fee_percent = (fee_amount / intermediate) * Decimal('100')
if fee_percent < Decimal('0.1'):
fee_percent = Decimal('0.1')
fee_amount = (intermediate * (fee_percent * Decimal('0.01'))).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
if fee_amount != 0:
if discount != 0:
# Only do this complicated math if we have to merge a good
# standing discount with a wall of shame fee
final = intermediate + fee_amount
discount = (-1 * ((final / amount) - Decimal('1')))
else:
# Just use wall of shame fee
discount = fee_percent * Decimal('-0.01')
if discount == 0:
# Make sure we handle the no discount normal case correctly
discount = None
e = event.Purchase(user)
DBSession.add(e)
DBSession.flush()
t = transaction.Purchase(e, account, discount)
DBSession.add(t)
DBSession.flush()
amount = Decimal(0)
for item, quantity in items.items():
item.in_stock -= quantity
line_amount = Decimal(item.price * quantity)
pli = transaction.PurchaseLineItem(t, line_amount, item, quantity,
item.price, item.wholesale)
DBSession.add(pli)
amount += line_amount
if discount:
amount = round(amount - (amount * discount), 2)
t.update_amount(amount)
if isinstance(account, Pool):
if account.balance < (account.credit_limit * -1):
owner = User.from_id(account.owner)
notify_pool_out_of_credit(owner, account)
return t
# Call this when a user puts money in the dropbox and needs to deposit it
# to their account
# If `merge==True`, then try to squash multiple deposits in a row together
@top_debtor_wrapper
def deposit(user, account, amount, merge=True):
assert(amount > 0.0)
assert(hasattr(user, "id"))
# Keep track of how much this deposit will be once merged (if needed)
deposit_total = amount
# Get recent deposits that we might merge with this one
events_to_delete = []
if merge:
recent_deposits = event.Deposit.get_user_recent(user)
for d in recent_deposits:
# Only look at transaction events with 1 CashDeposit transaction
if len(d.transactions) == 1 and d.transactions[0].type == 'cashdeposit':
t = d.transactions[0]
# Must be a deposit to the same account
if t.to_account_virt_id == account.id:
deposit_total += t.amount
events_to_delete.append(d)
# TODO (added on 2016/05/14): Make adding the new deposit and deleting
# the old ones a single atomic unit
# Add the new deposit (which may be a cumulative total)
prev = user.balance
e = event.Deposit(user)
DBSession.add(e)
DBSession.flush()
t = transaction.CashDeposit(e, account, deposit_total)
DBSession.add(t)
# And then delete the old events that we merged together
for e in events_to_delete:
undo_event(e, user)
return dict(prev=prev,
new=user.balance,
amount=deposit_total,
transaction=t,
event=e)
# Call this when a credit card transaction deposits money into an account
@top_debtor_wrapper
def cc_deposit(user, account, amount, txn_id, last4):
assert(amount > 0.0)
assert(hasattr(user, "id"))
prev = user.balance
e = event.Deposit(user)
DBSession.add(e)
DBSession.flush()
t = transaction.CCDeposit(e, account, amount, txn_id, last4)
DBSession.add(t)
return dict(prev=prev,
new=user.balance,
amount=amount,
transaction=t,
event=e)
# Call this to deposit bitcoins to the user account
@top_debtor_wrapper
def bitcoin_deposit(user, amount, btc_transaction, address, amount_btc):
assert(amount > 0.0)
assert(hasattr(user, "id"))
prev = user.balance
e = event.Deposit(user)
DBSession.add(e)
DBSession.flush()
t = transaction.BTCDeposit(e, user, amount, btc_transaction, address, amount_btc)
DBSession.add(t)
return dict(prev=prev,
new=user.balance,
amount=amount,
transaction=t)
# Call this to say money was given to chez betty but we don't know whose
# account to put it into
def temporary_deposit(amount):
assert(amount > 0.0)
return ephemeron.Ephemeron.add_decimal('deposit', amount)
# Call this to adjust a user's balance
@top_debtor_wrapper
def adjust_user_balance(user, adjustment, notes, admin):
e = event.Adjustment(admin, notes)
DBSession.add(e)
DBSession.flush()
t = transaction.Adjustment(e, user, adjustment)
DBSession.add(t)
return e
@top_debtor_wrapper
def transfer_user_money(sender, recipient, amount, notes, admin):
e = event.Adjustment(admin, notes)
DBSession.add(e)
DBSession.flush()
t = transaction.Transfer(e, sender, recipient, amount)
DBSession.add(t)
return e
# Call this when an admin restocks chezbetty
def restock(items, global_cost, donation, reimbursee, admin, timestamp=None, old_event_id=None):
e = event.Restock(admin, timestamp)
DBSession.add(e)
DBSession.flush()
t = transaction.Restock(e, Decimal(global_cost), reimbursee)
DBSession.add(t)
DBSession.flush()
if donation != Decimal(0):
d = transaction.Donation(e, donation, reimbursee)
DBSession.add(d)
DBSession.flush()
# Start with the global cost when calculating the total amount
amount = Decimal(global_cost)
# Add all of the items as subtransactions
for thing, quantity, total, wholesale, coupon, salestax, btldeposit in items:
if type(thing) is Item:
item = thing
# Add the stock to the item
item.in_stock += quantity
# Make sure the item is enabled (now that we have some in stock)
item.enabled = True
# Create a subtransaction to track that this item was added
rli = transaction.RestockLineItem(t, total, item, quantity, wholesale, coupon, salestax, btldeposit)
DBSession.add(rli)
#amount += Decimal(total)
elif type(thing) is Box:
box = thing
# Create a subtransaction to record that the box was restocked
rlb = transaction.RestockLineBox(t, total, box, quantity, wholesale, coupon, salestax, btldeposit)
DBSession.add(rlb)
DBSession.flush()
# Iterate all the subitems and update the stock
for itembox in box.items:
subitem = itembox.item
subquantity = itembox.quantity * quantity
subitem.enabled = True
subitem.in_stock += subquantity
rlbi = transaction.RestockLineBoxItem(rlb, subitem, subquantity)
DBSession.add(rlbi)
amount += Decimal(total)
t.update_amount(amount)
# See if there are attached receipts to the old event that should
# be transferred to this new event.
if old_event_id != None:
receipt.Receipt.transfer(old_event_id, e.id)
return e
# Call this when a user runs inventory
def reconcile_items(items, admin):
e = event.Inventory(admin)
DBSession.add(e)
DBSession.flush()
t = transaction.Inventory(e)
DBSession.add(t)
DBSession.flush()
total_amount_missing = Decimal(0)
for item, quantity in items.items():
# Record the restock line item even if the number hasn't changed.
# This lets us track when we have counted items.
quantity_missing = item.in_stock - quantity
line_amount = quantity_missing * item.wholesale
ili = transaction.InventoryLineItem(t, line_amount, item, item.in_stock,
quantity, item.wholesale)
DBSession.add(ili)
total_amount_missing += ili.amount
item.in_stock = quantity
t.update_amount(total_amount_missing)
DBSession.add(t)
DBSession.flush()
return t
# Call this when the cash box gets emptied
def reconcile_safe(amount, admin):
assert(amount>=0)
e = event.EmptySafe(admin)
DBSession.add(e)
DBSession.flush()
safe_c = account.get_cash_account("safe")
expected_amount = safe_c.balance
amount_missing = expected_amount - amount
if amount_missing != 0.0:
# If the amount in the safe doesn't match what we expected there to
# be, we need to adjust the amount in the cash box be transferring
# to or from a null account
if amount_missing > 0:
# We got less in the box than we expected
# Move money from the safe account to null with transaction type
# "lost"
t1 = transaction.Lost(e, account.get_cash_account("safe"), amount_missing)
DBSession.add(t1)
else:
# We got more in the box than expected! Use a found transaction
# to reconcile the difference
t1 = transaction.Found(e, account.get_cash_account("safe"), abs(amount_missing))
DBSession.add(t1)
# Now move all the money from the safe to chezbetty
t2 = transaction.EmptySafe(e, amount)
DBSession.add(t2)
return e
# Call this to move all of the money from the cash box to the safe.
# We don't actually count the amount, so we do no reconciling here, but it
# means that money isn't sitting in the store.
def cashbox_to_safe(admin):
e = event.EmptyCashBox(admin)
DBSession.add(e)
DBSession.flush()
t = transaction.EmptyCashBox(e)
DBSession.add(t)
return e
# Call this to move money from the safe to the bank.
def safe_to_bank(amount, admin):
assert(amount>=0)
e = event.EmptySafe(admin)
DBSession.add(e)
DBSession.flush()
t = transaction.EmptySafe(e, amount)
DBSession.add(t)
return e
# Call this when bitcoins are converted to USD
def reconcile_bitcoins(amount, admin, expected_amount=None):
assert(amount>0)
e = event.EmptyBitcoin(admin)
DBSession.add(e)
DBSession.flush()
btcbox_c = account.get_cash_account("btcbox")
if expected_amount == None:
expected_amount = btcbox_c.balance
amount_missing = expected_amount - amount
if amount_missing != 0.0:
# Value of bitcoins fluctated and we didn't make as much as we expected
if amount_missing > 0:
# We got less in bitcoins than we expected
# Move money from the btcbox account to null with transaction type
# "lost"
t1 = transaction.Lost(e, account.get_cash_account("btcbox"), amount_missing)
DBSession.add(t1)
else:
# We got more in bitcoins than expected! Use a found transaction
# to reconcile the difference
t1 = transaction.Found(e, account.get_cash_account("btcbox"), abs(amount_missing))
DBSession.add(t1)
# Now move all the money from the bitcoin box to chezbetty
t2 = transaction.EmptyBitcoin(e, amount)
DBSession.add(t2)
return expected_amount
# Call this to make a miscellaneous adjustment to the chezbetty account
def reconcile_misc(amount, notes, admin, timestamp=None):
assert(amount != 0.0)
e = event.Reconcile(admin, notes, timestamp)
DBSession.add(e)
DBSession.flush()
if amount < 0.0:
t = transaction.Lost(e, account.get_cash_account("chezbetty"), abs(amount))
else:
t = transaction.Found(e, account.get_cash_account("chezbetty"), amount)
DBSession.add(t)
return t
# Call this to make a cash donation to Chez Betty
def add_donation(amount, notes, admin, timestamp=None):
e = event.Donation(admin, notes, timestamp)
DBSession.add(e)
DBSession.flush()
t = transaction.Donation(e, amount)
DBSession.add(t)
return e
# Call this to withdraw cash funds from Chez Betty into another account
def add_withdrawal(amount, notes, reimbursee, admin, timestamp=None):
e = event.Withdrawal(admin, notes, timestamp)
DBSession.add(e)
DBSession.flush()
t = transaction.Withdrawal(e, amount, reimbursee)
DBSession.add(t)
return e
# Call this to reimburse a reimbursee
def add_reimbursement(amount, notes, reimbursee, admin, timestamp=None):
e = event.Reimbursement(admin, notes, timestamp)
DBSession.add(e)
DBSession.flush()
t = transaction.Reimbursement(e, amount, reimbursee)
DBSession.add(t)
return e
def upload_receipt(event, admin, rfile):
r = receipt.Receipt(event, admin, rfile)
DBSession.add(r)
DBSession.flush()
return r