Completed
Push — master ( 3312d8...577fc5 )
by
unknown
01:17
created

transfer_user_money()   A

Complexity

Conditions 1

Size

Total Lines 10

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 1
dl 0
loc 10
rs 9.4285
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
@top_debtor_wrapper
258
def deposit(user, account, amount):
259
    assert(amount > 0.0)
260
    assert(hasattr(user, "id"))
261
262
    # Keep track of how much this deposit will be once merged (if needed)
263
    deposit_total = amount
264
265
    # Get recent deposits that we might merge with this one
266
    events_to_delete = []
267
    recent_deposits = event.Deposit.get_user_recent(user)
268
    for d in recent_deposits:
269
        # Only look at transaction events with 1 CashDeposit transaction
270
        if len(d.transactions) == 1 and d.transactions[0].type == 'cashdeposit':
271
            t = d.transactions[0]
272
            # Must be a deposit to the same account
273
            if t.to_account_virt_id == account.id:
274
                deposit_total += t.amount
275
                events_to_delete.append(d)
276
277
278
    # TODO (added on 2016/05/14): Make adding the new deposit and deleting
279
    # the old ones a single atomic unit
280
281
    # Add the new deposit (which may be a cumulative total)
282
    prev = user.balance
283
    e = event.Deposit(user)
284
    DBSession.add(e)
285
    DBSession.flush()
286
    t = transaction.CashDeposit(e, account, deposit_total)
287
    DBSession.add(t)
288
289
    # And then delete the old events that we merged together
290
    for e in events_to_delete:
291
        undo_event(e, user)
292
293
    return dict(prev=prev,
294
                new=user.balance,
295
                amount=deposit_total,
296
                transaction=t,
297
                event=e)
298
299
300
# Call this when a credit card transaction deposits money into an account
301
@top_debtor_wrapper
302
def cc_deposit(user, account, amount, txn_id, last4):
303
    assert(amount > 0.0)
304
    assert(hasattr(user, "id"))
305
306
    prev = user.balance
307
    e = event.Deposit(user)
308
    DBSession.add(e)
309
    DBSession.flush()
310
    t = transaction.CCDeposit(e, account, amount, txn_id, last4)
311
    DBSession.add(t)
312
    return dict(prev=prev,
313
                new=user.balance,
314
                amount=amount,
315
                transaction=t,
316
                event=e)
317
318
319
# Call this to deposit bitcoins to the user account
320
@top_debtor_wrapper
321
def bitcoin_deposit(user, amount, btc_transaction, address, amount_btc):
322
    assert(amount > 0.0)
323
    assert(hasattr(user, "id"))
324
325
    prev = user.balance
326
    e = event.Deposit(user)
327
    DBSession.add(e)
328
    DBSession.flush()
329
    t = transaction.BTCDeposit(e, user, amount, btc_transaction, address, amount_btc)
330
    DBSession.add(t)
331
    return dict(prev=prev,
332
                new=user.balance,
333
                amount=amount,
334
                transaction=t)
335
336
337
# Call this to say money was given to chez betty but we don't know whose
338
# account to put it into
339
def temporary_deposit(amount):
340
    assert(amount > 0.0)
341
342
    return ephemeron.Ephemeron.add_decimal('deposit', amount)
343
344
345
# Call this to adjust a user's balance
346
@top_debtor_wrapper
347
def adjust_user_balance(user, adjustment, notes, admin):
348
    e = event.Adjustment(admin, notes)
349
    DBSession.add(e)
350
    DBSession.flush()
351
    t = transaction.Adjustment(e, user, adjustment)
352
    DBSession.add(t)
353
    return e
354
355
356
@top_debtor_wrapper
357
def transfer_user_money(sender, recipient, amount, notes, admin):
358
    e = event.Adjustment(admin, notes)
359
    DBSession.add(e)
360
    DBSession.flush()
361
    t1 = transaction.Adjustment(e, sender, -1*amount)
362
    DBSession.add(t1)
363
    t2 = transaction.Adjustment(e, recipient, amount)
364
    DBSession.add(t2)
365
    return e
366
367
368
# Call this when an admin restocks chezbetty
369
def restock(items, global_cost, donation, admin, timestamp=None):
370
    e = event.Restock(admin, timestamp)
371
    DBSession.add(e)
372
    DBSession.flush()
373
    t = transaction.Restock(e, Decimal(global_cost))
374
    DBSession.add(t)
375
    DBSession.flush()
376
    if donation != Decimal(0):
377
        d = transaction.Donation(e, donation)
378
        DBSession.add(d)
379
        DBSession.flush()
380
    # Start with the global cost when calculating the total amount
381
    amount = Decimal(global_cost)
382
383
    # Add all of the items as subtransactions
384
    for thing, quantity, total, wholesale, coupon, salestax, btldeposit in items:
385
        if type(thing) is Item:
386
            item = thing
387
            # Add the stock to the item
388
            item.in_stock += quantity
389
            # Make sure the item is enabled (now that we have some in stock)
390
            item.enabled = True
391
            # Create a subtransaction to track that this item was added
392
            rli = transaction.RestockLineItem(t, total, item, quantity, wholesale, coupon, salestax, btldeposit)
393
            DBSession.add(rli)
394
            #amount += Decimal(total)
395 View Code Duplication
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
396
        elif type(thing) is Box:
397
            box = thing
398
399
            # Create a subtransaction to record that the box was restocked
400
            rlb = transaction.RestockLineBox(t, total, box, quantity, wholesale, coupon, salestax, btldeposit)
401
            DBSession.add(rlb)
402
            DBSession.flush()
403
404
            # Iterate all the subitems and update the stock
405
            for itembox in box.items:
406
                subitem = itembox.item
407
                subquantity = itembox.quantity * quantity
408
                subitem.enabled = True
409
                subitem.in_stock += subquantity
410
411
                rlbi = transaction.RestockLineBoxItem(rlb, subitem, subquantity)
412
                DBSession.add(rlbi)
413
414
        amount += Decimal(total)
415
416
    t.update_amount(amount)
417
    return e
418
419
420
# Call this when a user runs inventory
421
def reconcile_items(items, admin):
422
    e = event.Inventory(admin)
423
    DBSession.add(e)
424
    DBSession.flush()
425
    t = transaction.Inventory(e)
426
    DBSession.add(t)
427
    DBSession.flush()
428
    total_amount_missing = Decimal(0)
429
    for item, quantity in items.items():
430
        # Record the restock line item even if the number hasn't changed.
431
        # This lets us track when we have counted items.
432
        quantity_missing = item.in_stock - quantity
433
        line_amount = quantity_missing * item.wholesale
434
        ili = transaction.InventoryLineItem(t, line_amount, item, item.in_stock,
435
                                quantity, item.wholesale)
436
        DBSession.add(ili)
437
        total_amount_missing += ili.amount
438
        item.in_stock = quantity
439
    t.update_amount(total_amount_missing)
440
    DBSession.add(t)
441
    DBSession.flush()
442
    return t
443
444
445
# Call this when the cash box gets emptied
446
def reconcile_safe(amount, admin):
447
    assert(amount>=0)
448
449
    e = event.EmptySafe(admin)
450
    DBSession.add(e)
451
    DBSession.flush()
452
453
    safe_c = account.get_cash_account("safe")
454
    expected_amount = safe_c.balance
455
    amount_missing = expected_amount - amount
456
457
    if amount_missing != 0.0:
458 View Code Duplication
        # If the amount in the safe doesn't match what we expected there to
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.
Loading history...
459
        # be, we need to adjust the amount in the cash box be transferring
460
        # to or from a null account
461
462
        if amount_missing > 0:
463
            # We got less in the box than we expected
464
            # Move money from the safe account to null with transaction type
465
            # "lost"
466
            t1 = transaction.Lost(e, account.get_cash_account("safe"), amount_missing)
467
            DBSession.add(t1)
468
469
        else:
470
            # We got more in the box than expected! Use a found transaction
471
            # to reconcile the difference
472
            t1 = transaction.Found(e, account.get_cash_account("safe"), abs(amount_missing))
473
            DBSession.add(t1)
474
475
476
    # Now move all the money from the safe to chezbetty
477
    t2 = transaction.EmptySafe(e, amount)
478
    DBSession.add(t2)
479
    return e
480
481
482
# Call this to move all of the money from the cash box to the safe.
483
# We don't actually count the amount, so we do no reconciling here, but it
484
# means that money isn't sitting in the store.
485
def cashbox_to_safe(admin):
486
    e = event.EmptyCashBox(admin)
487
    DBSession.add(e)
488
    DBSession.flush()
489
490
    t = transaction.EmptyCashBox(e)
491
    DBSession.add(t)
492
    return e
493
494
495
# Call this to move money from the safe to the bank.
496
def safe_to_bank(amount, admin):
497
    assert(amount>=0)
498
499
    e = event.EmptySafe(admin)
500
    DBSession.add(e)
501
    DBSession.flush()
502
503
    t = transaction.EmptySafe(e, amount)
504
    DBSession.add(t)
505
    return e
506
507
508
# Call this when bitcoins are converted to USD
509
def reconcile_bitcoins(amount, admin, expected_amount=None):
510
    assert(amount>0)
511
512
    e = event.EmptyBitcoin(admin)
513
    DBSession.add(e)
514
    DBSession.flush()
515
516
    btcbox_c = account.get_cash_account("btcbox")
517
    if expected_amount == None:
518
        expected_amount = btcbox_c.balance
519
    amount_missing = expected_amount - amount
520
521
    if amount_missing != 0.0:
522
        # Value of bitcoins fluctated and we didn't make as much as we expected
523
524
        if amount_missing > 0:
525
            # We got less in bitcoins than we expected
526
            # Move money from the btcbox account to null with transaction type
527
            # "lost"
528
            t1 = transaction.Lost(e, account.get_cash_account("btcbox"), amount_missing)
529
            DBSession.add(t1)
530
531
        else:
532
            # We got more in bitcoins than expected! Use a found transaction
533
            # to reconcile the difference
534
            t1 = transaction.Found(e, account.get_cash_account("btcbox"), abs(amount_missing))
535
            DBSession.add(t1)
536
537
538
    # Now move all the money from the bitcoin box to chezbetty
539
    t2 = transaction.EmptyBitcoin(e, amount)
540
    DBSession.add(t2)
541
    return expected_amount
542
543
544
# Call this to make a miscellaneous adjustment to the chezbetty account
545
def reconcile_misc(amount, notes, admin):
546
    assert(amount != 0.0)
547
548
    e = event.Reconcile(admin, notes)
549
    DBSession.add(e)
550
    DBSession.flush()
551
552
    if amount < 0.0:
553
        t = transaction.Lost(e, account.get_cash_account("chezbetty"), abs(amount))
554
    else:
555
        t = transaction.Found(e, account.get_cash_account("chezbetty"), amount)
556
    DBSession.add(t)
557
    return t
558
559
560
# Call this to make a cash donation to Chez Betty
561
def add_donation(amount, notes, admin, timestamp=None):
562
    e = event.Donation(admin, notes, timestamp)
563
    DBSession.add(e)
564
    DBSession.flush()
565
    t = transaction.Donation(e, amount)
566
    DBSession.add(t)
567
    return t
568
569
570
# Call this to withdraw cash funds from Chez Betty into another account
571
def add_withdrawal(amount, notes, admin, timestamp=None):
572
    e = event.Withdrawal(admin, notes, timestamp)
573
    DBSession.add(e)
574
    DBSession.flush()
575
    t = transaction.Withdrawal(e, amount)
576
    DBSession.add(t)
577
    return t
578
579
def upload_receipt(event, admin, rfile):
580
    r = receipt.Receipt(event, admin, rfile)
581
    DBSession.add(r)
582
    DBSession.flush()
583
    return r
584