Completed
Push — master ( ba470c...1324e2 )
by
unknown
01:42
created

add_reimbursement()   A

Complexity

Conditions 1

Size

Total Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
dl 0
loc 7
rs 9.4285
c 0
b 0
f 0
1
import functools
2
3
from .models.model import *
4
from .models import event
5
from .models import transaction
6
from .models import account
7
from .models.pool import Pool
8
from .models.user import User
9
from .models import request
10
from .models import receipt
11
from .models.item import Item
12
from .models.box import Box
13
from .models import box_item
14
from .models import item_vendor
15
from .models import box_vendor
16
from .models import ephemeron
17
18
from .utility import notify_pool_out_of_credit
19
from .utility import notify_new_top_wall_of_shame
20
21
import math
22
23
24
def top_debtor_wrapper(fn):
25
    '''Wrapper function for transactions that watches for a new top debtor.
26
27
    Should wrap any function that creates a purchase or deposit transaction.
28
    Can't put this inside the Transaction class b/c the add/flush operations are
29
    at a higher level.'''
30
    @functools.wraps(fn)
31
    def wrapper(*args, **kwargs):
32
        # Record top debtor before new transaction
33
        old_top_debtor = DBSession.query(User).order_by(User.balance).limit(1).one()
34
35
        # Execute transaction. Txn function should call add() and flush()
36
        ret = fn(*args, **kwargs)
37
38
        # Check whether the top debtor has changed
39
        new_top_debtor = DBSession.query(User).order_by(User.balance).limit(1).one()
40
        print(old_top_debtor, new_top_debtor)
41
        if new_top_debtor != old_top_debtor:
42
            notify_new_top_wall_of_shame(new_top_debtor)
43
44
        return ret
45
46
    return wrapper
47
48
49
def can_undo_event(e):
50
    if e.type != 'deposit' and e.type != 'purchase' and e.type != 'restock' \
51
       and e.type != 'inventory' and e.type != 'emptycashbox' \
52
       and e.type != 'donation' and e.type != 'withdrawal' \
53
       and e.type != 'reimbursement':
54
        return False
55
    if e.deleted:
56
        return False
57
    return True
58
59
60
# Call this to remove an event from chez betty. Only works with cash deposits
61
def undo_event(e, user):
62
    assert(can_undo_event(e))
63
64
    line_items = {}
65
66
    for t in e.transactions:
67
68
        if t.to_account_virt:
69
            t.to_account_virt.balance -= t.amount
70
        if t.fr_account_virt:
71
            t.fr_account_virt.balance += t.amount
72
        if t.to_account_cash:
73
            t.to_account_cash.balance -= t.amount
74
        if t.fr_account_cash:
75
            t.fr_account_cash.balance += t.amount
76
77
        if t.type == 'purchase':
78
            # Re-add the stock to the items that were purchased
79
            for s in t.subtransactions:
80
                line_items[s.item_id] = s.quantity
81
                Item.from_id(s.item_id).in_stock += s.quantity
82
83
        elif t.type == 'restock':
84
            # Include the global cost so we can repopulate the box on the
85
            # restock page.
86
            line_items['global_cost'] = '{}'.format(t.amount_restock_cost)
87
88
            # Record who we reimbursed this to
89
            if t.to_account_cash:
90
                line_items['reimbursee'] = t.to_account_cash.id
91
92
            # Add all of the boxes and items to the return list
93
            # Also remove the stock this restock added to each item
94
            for i,s in zip(range(len(t.subtransactions)), t.subtransactions):
95
                if s.type == 'restocklineitem':
96
                    item = Item.from_id(s.item_id)
97
                    line_items[i] = '{},{},{},{},{},{},{}'.format(
98
                        'item', s.item_id, s.quantity, s.wholesale,
99
                        s.coupon_amount, s.sales_tax, s.bottle_deposit)
100
                    item.in_stock -= s.quantity
101
                elif s.type == 'restocklinebox':
102
                    line_items[i] = '{},{},{},{},{},{},{}'.format(
103
                        'box', s.box_id, s.quantity, s.wholesale,
104
                        s.coupon_amount, s.sales_tax, s.bottle_deposit)
105
                    for ss in s.subsubtransactions:
106
                        item = Item.from_id(ss.item_id)
107
                        item.in_stock -= ss.quantity
108
109
        elif t.type == 'inventory':
110
            # Change the stock of all the items by reversing the inventory count
111
            for s in t.subtransactions:
112
                quantity_diff = s.quantity - s.quantity_counted
113
                s.item.in_stock += quantity_diff
114
                line_items[s.item_id] = s.quantity_counted
115
116
        elif t.type == 'donation':
117
            line_items['donation'] = '{}'.format(t.amount)
118
119
        # Don't need anything for emptycashbox. On those transactions no
120
        # other tables are changed.
121
122
123
    # Just need to delete the event. All transactions will understand they
124
    # were deleted as well.
125
    e.delete(user)
126
127
    return line_items
128
129
def can_delete_item(item):
130
    if len(item.boxes) == 0 and\
131
       len(item.vendors) == 0 and\
132
       len(item.subtransactions) == 0 and\
133
       len(item.subsubtransactions) == 0:
134
       return True
135
    return False
136
137
def delete_item(item):
138
    boxitems = DBSession.query(box_item.BoxItem).filter(box_item.BoxItem.item_id==item.id).all()
139
    for bi in boxitems:
140
        DBSession.delete(bi)
141
    itemvendors = DBSession.query(item_vendor.ItemVendor).filter(item_vendor.ItemVendor.item_id==item.id).all()
142
    for iv in itemvendors:
143
        DBSession.delete(iv)
144
    DBSession.delete(item)
145
146
def can_delete_box(box):
147
    if len(box.items) == 0 and\
148
       len(box.vendors) == 0 and\
149
       len(box.subtransactions) == 0:
150
       return True
151
    return False
152
153
def delete_box(box):
154
    boxitems = DBSession.query(box_item.BoxItem).filter(box_item.BoxItem.box_id==box.id).all()
155
    for bi in boxitems:
156
        DBSession.delete(bi)
157
    boxvendors = DBSession.query(box_vendor.BoxVendor).filter(box_vendor.BoxVendor.box_id==box.id).all()
158
    for bv in boxvendors:
159
        DBSession.delete(bv)
160
    DBSession.delete(box)
161
162
163
# Call this to make a new item request
164
def new_request(user, request_text, vendor, vendor_url=None):
165
    r = request.Request(user, request_text, vendor, vendor_url)
166
    DBSession.add(r)
167
    DBSession.flush()
168
    return r
169
170
171
# Call this to let a user purchase items
172
@top_debtor_wrapper
173
def purchase(user, account, items):
174
    assert(hasattr(user, "id"))
175
    assert(len(items) > 0)
176
177
    # TODO: Parameterize
178
    discount = Decimal(0)
179
    if user.balance > 20.0:
180
        discount = Decimal('0.05')
181
182
    # Need to calculate a total
183
    amount = Decimal(0)
184
    for item, quantity in items.items():
185
        amount += Decimal(item.price * quantity)
186
187
    intermediate = amount - (amount * discount).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
188
189
    # Calculate a potential wall of shame fee
190
    fee = None
191
    fee_amount = Decimal(0)
192
    result = user.balance - intermediate
193
    fee_percent = Decimal(0)
194
    if result <= Decimal('-5'):
195
        remainder = (user.balance - intermediate) * Decimal('-1')
196
        offset = user.balance * Decimal('-1')
197
        if user.balance > Decimal('-5'):
198
            offset = Decimal('5')
199
        fee_percent = math.floor(offset / Decimal('5')) * Decimal('5')
200
201
        while True:
202
            extra = remainder - offset
203
204
            if remainder < fee_percent + Decimal('5'):
205
                fee_amount += ((fee_percent * Decimal('0.01')) * extra)
206
                break
207
208
            else:
209
                fee_amount += ((fee_percent * Decimal('0.01')) * (fee_percent + Decimal('5') - offset))
210
                fee_percent += Decimal('5')
211
                offset = fee_percent
212
213
        fee_percent = (fee_amount / intermediate) * Decimal('100')
214
        if fee_percent < Decimal('0.1'):
215
            fee_percent = Decimal('0.1')
216
217
        fee_amount = (intermediate * (fee_percent * Decimal('0.01'))).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
218
219
220
    if fee_amount != 0:
221
        if discount != 0:
222
            # Only do this complicated math if we have to merge a good
223
            # standing discount with a wall of shame fee
224
            final = intermediate + fee_amount
225
            discount = (-1 * ((final / amount) - Decimal('1')))
226
        else:
227
            # Just use wall of shame fee
228
            discount = fee_percent * Decimal('-0.01')
229
230
    if discount == 0:
231
        # Make sure we handle the no discount normal case correctly
232
        discount = None
233
234
    e = event.Purchase(user)
235
    DBSession.add(e)
236
    DBSession.flush()
237
    t = transaction.Purchase(e, account, discount)
238
    DBSession.add(t)
239
    DBSession.flush()
240
    amount = Decimal(0)
241
    for item, quantity in items.items():
242
        item.in_stock -= quantity
243
        line_amount = Decimal(item.price * quantity)
244
        pli = transaction.PurchaseLineItem(t, line_amount, item, quantity,
245
                                           item.price, item.wholesale)
246
        DBSession.add(pli)
247
        amount += line_amount
248
    if discount:
249
        amount = round(amount - (amount * discount), 2)
250
    t.update_amount(amount)
251
252
    if isinstance(account, Pool):
253
        if account.balance < (account.credit_limit * -1):
254
            owner = User.from_id(account.owner)
255
            notify_pool_out_of_credit(owner, account)
256
257
    return t
258
259
260
# Call this when a user puts money in the dropbox and needs to deposit it
261
# to their account
262
# If `merge==True`, then try to squash multiple deposits in a row together
263
@top_debtor_wrapper
264
def deposit(user, account, amount, merge=True):
265
    assert(amount > 0.0)
266
    assert(hasattr(user, "id"))
267
268
    # Keep track of how much this deposit will be once merged (if needed)
269
    deposit_total = amount
270
271
    # Get recent deposits that we might merge with this one
272
    events_to_delete = []
273
    if merge:
274
        recent_deposits = event.Deposit.get_user_recent(user)
275
        for d in recent_deposits:
276
            # Only look at transaction events with 1 CashDeposit transaction
277
            if len(d.transactions) == 1 and d.transactions[0].type == 'cashdeposit':
278
                t = d.transactions[0]
279
                # Must be a deposit to the same account
280
                if t.to_account_virt_id == account.id:
281
                    deposit_total += t.amount
282
                    events_to_delete.append(d)
283
284
285
    # TODO (added on 2016/05/14): Make adding the new deposit and deleting
286
    # the old ones a single atomic unit
287
288
    # Add the new deposit (which may be a cumulative total)
289
    prev = user.balance
290
    e = event.Deposit(user)
291
    DBSession.add(e)
292
    DBSession.flush()
293
    t = transaction.CashDeposit(e, account, deposit_total)
294
    DBSession.add(t)
295
296
    # And then delete the old events that we merged together
297
    for e in events_to_delete:
298
        undo_event(e, user)
299
300
    return dict(prev=prev,
301
                new=user.balance,
302
                amount=deposit_total,
303
                transaction=t,
304
                event=e)
305
306
307
# Call this when a credit card transaction deposits money into an account
308
@top_debtor_wrapper
309
def cc_deposit(user, account, amount, txn_id, last4):
310
    assert(amount > 0.0)
311
    assert(hasattr(user, "id"))
312
313
    prev = user.balance
314
    e = event.Deposit(user)
315
    DBSession.add(e)
316
    DBSession.flush()
317
    t = transaction.CCDeposit(e, account, amount, txn_id, last4)
318
    DBSession.add(t)
319
    return dict(prev=prev,
320
                new=user.balance,
321
                amount=amount,
322
                transaction=t,
323
                event=e)
324
325
326
# Call this to deposit bitcoins to the user account
327
@top_debtor_wrapper
328
def bitcoin_deposit(user, amount, btc_transaction, address, amount_btc):
329
    assert(amount > 0.0)
330
    assert(hasattr(user, "id"))
331
332
    prev = user.balance
333
    e = event.Deposit(user)
334
    DBSession.add(e)
335
    DBSession.flush()
336
    t = transaction.BTCDeposit(e, user, amount, btc_transaction, address, amount_btc)
337
    DBSession.add(t)
338
    return dict(prev=prev,
339
                new=user.balance,
340
                amount=amount,
341
                transaction=t)
342
343
344
# Call this to say money was given to chez betty but we don't know whose
345
# account to put it into
346
def temporary_deposit(amount):
347
    assert(amount > 0.0)
348
349
    return ephemeron.Ephemeron.add_decimal('deposit', amount)
350
351
352
# Call this to adjust a user's balance
353
@top_debtor_wrapper
354
def adjust_user_balance(user, adjustment, notes, admin):
355
    e = event.Adjustment(admin, notes)
356
    DBSession.add(e)
357
    DBSession.flush()
358
    t = transaction.Adjustment(e, user, adjustment)
359
    DBSession.add(t)
360
    return e
361
362
363
@top_debtor_wrapper
364
def transfer_user_money(sender, recipient, amount, notes, admin):
365
    e = event.Adjustment(admin, notes)
366
    DBSession.add(e)
367
    DBSession.flush()
368
    t1 = transaction.Adjustment(e, sender, -1*amount)
369
    DBSession.add(t1)
370
    t2 = transaction.Adjustment(e, recipient, amount)
371
    DBSession.add(t2)
372
    return e
373
374
375
# Call this when an admin restocks chezbetty
376
def restock(items, global_cost, donation, reimbursee, admin, timestamp=None):
377
    e = event.Restock(admin, timestamp)
378
    DBSession.add(e)
379
    DBSession.flush()
380
    t = transaction.Restock(e, Decimal(global_cost), reimbursee)
381
    DBSession.add(t)
382
    DBSession.flush()
383
    if donation != Decimal(0):
384
        d = transaction.Donation(e, donation, reimbursee)
385
        DBSession.add(d)
386
        DBSession.flush()
387
    # Start with the global cost when calculating the total amount
388
    amount = Decimal(global_cost)
389
390
    # Add all of the items as subtransactions
391
    for thing, quantity, total, wholesale, coupon, salestax, btldeposit in items:
392
        if type(thing) is Item:
393
            item = thing
394
            # Add the stock to the item
395 View Code Duplication
            item.in_stock += quantity
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
396
            # Make sure the item is enabled (now that we have some in stock)
397
            item.enabled = True
398
            # Create a subtransaction to track that this item was added
399
            rli = transaction.RestockLineItem(t, total, item, quantity, wholesale, coupon, salestax, btldeposit)
400
            DBSession.add(rli)
401
            #amount += Decimal(total)
402
403
        elif type(thing) is Box:
404
            box = thing
405
406
            # Create a subtransaction to record that the box was restocked
407
            rlb = transaction.RestockLineBox(t, total, box, quantity, wholesale, coupon, salestax, btldeposit)
408
            DBSession.add(rlb)
409
            DBSession.flush()
410
411
            # Iterate all the subitems and update the stock
412
            for itembox in box.items:
413
                subitem = itembox.item
414
                subquantity = itembox.quantity * quantity
415
                subitem.enabled = True
416
                subitem.in_stock += subquantity
417
418
                rlbi = transaction.RestockLineBoxItem(rlb, subitem, subquantity)
419
                DBSession.add(rlbi)
420
421
        amount += Decimal(total)
422
423
    t.update_amount(amount)
424
    return e
425
426
427
# Call this when a user runs inventory
428
def reconcile_items(items, admin):
429
    e = event.Inventory(admin)
430
    DBSession.add(e)
431
    DBSession.flush()
432
    t = transaction.Inventory(e)
433
    DBSession.add(t)
434
    DBSession.flush()
435
    total_amount_missing = Decimal(0)
436
    for item, quantity in items.items():
437
        # Record the restock line item even if the number hasn't changed.
438
        # This lets us track when we have counted items.
439
        quantity_missing = item.in_stock - quantity
440
        line_amount = quantity_missing * item.wholesale
441
        ili = transaction.InventoryLineItem(t, line_amount, item, item.in_stock,
442
                                quantity, item.wholesale)
443
        DBSession.add(ili)
444
        total_amount_missing += ili.amount
445
        item.in_stock = quantity
446
    t.update_amount(total_amount_missing)
447
    DBSession.add(t)
448
    DBSession.flush()
449
    return t
450
451
452
# Call this when the cash box gets emptied
453
def reconcile_safe(amount, admin):
454
    assert(amount>=0)
455
456
    e = event.EmptySafe(admin)
457
    DBSession.add(e)
458 View Code Duplication
    DBSession.flush()
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
459
460
    safe_c = account.get_cash_account("safe")
461
    expected_amount = safe_c.balance
462
    amount_missing = expected_amount - amount
463
464
    if amount_missing != 0.0:
465
        # If the amount in the safe doesn't match what we expected there to
466
        # be, we need to adjust the amount in the cash box be transferring
467
        # to or from a null account
468
469
        if amount_missing > 0:
470
            # We got less in the box than we expected
471
            # Move money from the safe account to null with transaction type
472
            # "lost"
473
            t1 = transaction.Lost(e, account.get_cash_account("safe"), amount_missing)
474
            DBSession.add(t1)
475
476
        else:
477
            # We got more in the box than expected! Use a found transaction
478
            # to reconcile the difference
479
            t1 = transaction.Found(e, account.get_cash_account("safe"), abs(amount_missing))
480
            DBSession.add(t1)
481
482
483
    # Now move all the money from the safe to chezbetty
484
    t2 = transaction.EmptySafe(e, amount)
485
    DBSession.add(t2)
486
    return e
487
488
489
# Call this to move all of the money from the cash box to the safe.
490
# We don't actually count the amount, so we do no reconciling here, but it
491
# means that money isn't sitting in the store.
492
def cashbox_to_safe(admin):
493
    e = event.EmptyCashBox(admin)
494
    DBSession.add(e)
495
    DBSession.flush()
496
497
    t = transaction.EmptyCashBox(e)
498
    DBSession.add(t)
499
    return e
500
501
502
# Call this to move money from the safe to the bank.
503
def safe_to_bank(amount, admin):
504
    assert(amount>=0)
505
506
    e = event.EmptySafe(admin)
507
    DBSession.add(e)
508
    DBSession.flush()
509
510
    t = transaction.EmptySafe(e, amount)
511
    DBSession.add(t)
512
    return e
513
514
515
# Call this when bitcoins are converted to USD
516
def reconcile_bitcoins(amount, admin, expected_amount=None):
517
    assert(amount>0)
518
519
    e = event.EmptyBitcoin(admin)
520
    DBSession.add(e)
521
    DBSession.flush()
522
523
    btcbox_c = account.get_cash_account("btcbox")
524
    if expected_amount == None:
525
        expected_amount = btcbox_c.balance
526
    amount_missing = expected_amount - amount
527
528
    if amount_missing != 0.0:
529
        # Value of bitcoins fluctated and we didn't make as much as we expected
530
531
        if amount_missing > 0:
532
            # We got less in bitcoins than we expected
533
            # Move money from the btcbox account to null with transaction type
534
            # "lost"
535
            t1 = transaction.Lost(e, account.get_cash_account("btcbox"), amount_missing)
536
            DBSession.add(t1)
537
538
        else:
539
            # We got more in bitcoins than expected! Use a found transaction
540
            # to reconcile the difference
541
            t1 = transaction.Found(e, account.get_cash_account("btcbox"), abs(amount_missing))
542
            DBSession.add(t1)
543
544
545
    # Now move all the money from the bitcoin box to chezbetty
546
    t2 = transaction.EmptyBitcoin(e, amount)
547
    DBSession.add(t2)
548
    return expected_amount
549
550
551
# Call this to make a miscellaneous adjustment to the chezbetty account
552
def reconcile_misc(amount, notes, admin):
553
    assert(amount != 0.0)
554
555
    e = event.Reconcile(admin, notes)
556
    DBSession.add(e)
557
    DBSession.flush()
558
559
    if amount < 0.0:
560
        t = transaction.Lost(e, account.get_cash_account("chezbetty"), abs(amount))
561
    else:
562
        t = transaction.Found(e, account.get_cash_account("chezbetty"), amount)
563
    DBSession.add(t)
564
    return t
565
566
567
# Call this to make a cash donation to Chez Betty
568
def add_donation(amount, notes, admin, timestamp=None):
569
    e = event.Donation(admin, notes, timestamp)
570
    DBSession.add(e)
571
    DBSession.flush()
572
    t = transaction.Donation(e, amount)
573
    DBSession.add(t)
574
    return e
575
576
577
# Call this to withdraw cash funds from Chez Betty into another account
578
def add_withdrawal(amount, notes, reimbursee, admin, timestamp=None):
579
    e = event.Withdrawal(admin, notes, timestamp)
580
    DBSession.add(e)
581
    DBSession.flush()
582
    t = transaction.Withdrawal(e, amount, reimbursee)
583
    DBSession.add(t)
584
    return e
585
586
587
# Call this to reimburse a reimbursee
588
def add_reimbursement(amount, reimbursee, admin, timestamp=None):
589
    e = event.Reimbursement(admin, timestamp)
590
    DBSession.add(e)
591
    DBSession.flush()
592
    t = transaction.Reimbursement(e, amount, reimbursee)
593
    DBSession.add(t)
594
    return e
595
596
597
def upload_receipt(event, admin, rfile):
598
    r = receipt.Receipt(event, admin, rfile)
599
    DBSession.add(r)
600
    DBSession.flush()
601
    return r
602