Completed
Push — master ( 2b11ba...53172e )
by
unknown
01:13
created

deposit()   F

Complexity

Conditions 9

Size

Total Lines 42

Duplication

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