Issues (24)

chezbetty/datalayer.py (2 issues)

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