Completed
Push — master ( 9e58f4...6e6bb8 )
by Pat
01:02
created

chezbetty.models.Transaction.get_balance()   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
from .model import *
2
from . import account
3
from . import event
4
from . import item
5
from . import box
6
from . import user
7
from chezbetty import utility
8
9
import arrow
10
from pyramid.threadlocal import get_current_registry
11
12
13
class Transaction(Base):
14
    __tablename__ = 'transactions'
15
16
    id                 = Column(Integer, primary_key=True, nullable=False)
17
18
    event_id           = Column(Integer, ForeignKey("events.id"))
19
20
    to_account_virt_id = Column(Integer, ForeignKey("accounts.id"))
21
    fr_account_virt_id = Column(Integer, ForeignKey("accounts.id"))
22
    to_account_cash_id = Column(Integer, ForeignKey("accounts.id"))
23
    fr_account_cash_id = Column(Integer, ForeignKey("accounts.id"))
24
    amount             = Column(Numeric, nullable=False)
25
26
                                        # Virtual Transaction Meaning     # Cash Transaction Meaning  # Notes required?
27
    type = Column(Enum("purchase",      # user_account -> chezbetty.        None
28
                       "deposit",
29
                       "cashdeposit",   # null         -> user_account.     null      -> cashbox.
30
                       "ccdeposit",     # null         -> user_account.     null      -> chezbetty
31
                       "btcdeposit",    # null         -> user_account      null      -> btcbox
32
                       "adjustment",    # chezbetty   <-> user              None                            Yes
33
                       "restock",       # chezbetty    -> null              chezbetty -> null
34
                       "inventory",     # chezbetty   <-> null              None
35
                       "emptycashbox",  # None                              cashbox   -> chezbetty
36
                       "emptybitcoin",  # None                              btcbox    -> chezbetty
37
                       "lost",          # None                              chezbetty/cashbox/btcbox -> null       Yes
38
                       "found",         # None                              null      -> chezbetty/cashbox/btcbox  Yes
39
                       "donation",      # null         -> chezbetty         null      -> chezbetty          Yes
40
                       "withdrawal",    # chezbetty    -> null              chezbetty -> null               Yes
41
                       name="transaction_type"), nullable=False)
42
    __mapper_args__ = {'polymorphic_on': type}
43
44
45
    to_account_virt = relationship(account.Account,
46
        foreign_keys=[to_account_virt_id,],
47
        backref="transactions_to_virt"
48
    )
49
    fr_account_virt = relationship(account.Account,
50
        foreign_keys=[fr_account_virt_id,],
51
        backref="transactions_from_virt"
52
    )
53
54
    to_account_cash = relationship(account.Account,
55
        foreign_keys=[to_account_cash_id,],
56
        backref="transactions_to_cash"
57
    )
58
    fr_account_cash = relationship(account.Account,
59
        foreign_keys=[fr_account_cash_id,],
60
        backref="transactions_from_cash"
61
    )
62
    event = relationship(event.Event,
63
        foreign_keys=[event_id,],
64
        backref="transactions"
65
    )
66
67
68
    def __init__(self, event, fr_acct_virt, to_acct_virt, fr_acct_cash, to_acct_cash, amount):
69
        self.to_account_virt_id = to_acct_virt.id if to_acct_virt else None
70
        self.fr_account_virt_id = fr_acct_virt.id if fr_acct_virt else None
71
        self.to_account_cash_id = to_acct_cash.id if to_acct_cash else None
72
        self.fr_account_cash_id = fr_acct_cash.id if fr_acct_cash else None
73
74
        self.to_acct_virt = to_acct_virt
75
        self.fr_acct_virt = fr_acct_virt
76
        self.to_acct_cash = to_acct_cash
77
        self.fr_acct_cash = fr_acct_cash
78
79
        self.event_id = event.id
80
        self.amount = amount
81
82
        # Update the balances of the accounts we are moving money between
83
        if to_acct_virt:
84
            to_acct_virt.balance += self.amount
85
        if fr_acct_virt:
86
            fr_acct_virt.balance -= self.amount
87
88
        if to_acct_cash:
89
            to_acct_cash.balance += self.amount
90
        if fr_acct_cash:
91
            fr_acct_cash.balance -= self.amount
92
93
    def update_amount(self, amount):
94
        # Remove the balance we added before (upon init or last update_amount)
95
        if self.to_acct_virt:
96
            self.to_acct_virt.balance -= self.amount
97
        if self.fr_acct_virt:
98
            self.fr_acct_virt.balance += self.amount
99
        if self.to_acct_cash:
100
            self.to_acct_cash.balance -= self.amount
101
        if self.fr_acct_cash:
102
            self.fr_acct_cash.balance += self.amount
103
104
        # Save the amount so we can subtract it later if needed
105
        self.amount = amount
106
107
        # Apply the new amount
108
        if self.to_acct_virt:
109
            self.to_acct_virt.balance += self.amount
110
        if self.fr_acct_virt:
111
            self.fr_acct_virt.balance -= self.amount
112
        if self.to_acct_cash:
113
            self.to_acct_cash.balance += self.amount
114
        if self.fr_acct_cash:
115
            self.fr_acct_cash.balance -= self.amount
116
117
    @classmethod
118
    def from_id(cls, id):
119
        return DBSession.query(cls)\
120
                        .filter(cls.id == id).one()
121
122
    @classmethod
123
    def get_balance(cls, trans_type, account_obj):
124
        return DBSession.query(coalesce(func.sum(cls.amount), 0).label("balance"))\
125
                     .join(event.Event)\
126
                     .filter(or_(cls.fr_account_cash_id==account_obj.id,
127
                                 cls.to_account_cash_id==account_obj.id,
128
                                 cls.fr_account_virt_id==account_obj.id,
129
                                 cls.to_account_virt_id==account_obj.id))\
130
                     .filter(cls.type==trans_type)\
131
                     .filter(event.Event.deleted==False).one()
132
133
    @classmethod
134
    def count(cls, trans_type=None):
135
        if not trans_type:
136
            return DBSession.query(func.count(cls.id).label('c'))\
137
                            .join(event.Event)\
138
                            .filter(event.Event.deleted==False).one().c
139
        else:
140
            return DBSession.query(func.count(cls.id).label('c'))\
141
                            .join(event.Event)\
142
                            .filter(cls.type==trans_type)\
143
                            .filter(event.Event.deleted==False).one().c
144
145
    @classmethod
146
    def total(cls, start=None, end=None):
147
        r = DBSession.query(func.sum(cls.amount).label('a'))\
148
                        .join(event.Event)\
149
                        .filter(event.Event.deleted==False)
150
151
        if start:
152
            r = r.filter(event.Event.timestamp>=start)
153
        if end:
154
            r = r.filter(event.Event.timestamp<end)
155
156
        return r.one().a or Decimal(0.0)
157
158
    # Get the total amount of discounts people have received for keeping
159
    # money in their account
160
    @classmethod
161
    def discounts(cls, start=None, end=None):
162
        r = DBSession.query(func.sum((cls.amount / (1-cls.discount)) - cls.amount).label('d'))\
163
                        .join(event.Event)\
164
                        .filter(cls.discount > 0)\
165
                        .filter(event.Event.deleted==False)
166
167
        if start:
168
            r = r.filter(event.Event.timestamp>=start)
169
        if end:
170
            r = r.filter(event.Event.timestamp<end)
171
172
        return r.one().d or Decimal(0.0)
173
174
    # Get the total amount of fees people have paid for being in debt
175
    @classmethod
176
    def fees(cls, start=None, end=None):
177
        r = DBSession.query(func.sum((cls.amount / (1-cls.discount)) - cls.amount).label('f'))\
178
                        .join(event.Event)\
179
                        .filter(cls.discount < 0)\
180
                        .filter(event.Event.deleted==False)
181
182
        if start:
183
            r = r.filter(event.Event.timestamp>=start)
184
        if end:
185
            r = r.filter(event.Event.timestamp<end)
186
187
        return r.one().f or Decimal(0.0)
188
189
    # Returns an array of tuples where the first item is a millisecond timestamp,
190
    # the next is the total amount of debt, and the next is the total amount
191
    # of stored money for users.
192
    @classmethod
193
    def get_balance_total_daily(cls):
194
        rows = DBSession.query(cls.amount,
195
                               cls.type,
196
                               cls.to_account_virt_id,
197
                               cls.fr_account_virt_id,
198
                               event.Event.timestamp)\
199
                        .join(event.Event)\
200
                        .filter(event.Event.deleted==False)\
201
                        .filter(or_(
202
                                  cls.type=='purchase',
203
                                  cls.type=='cashdeposit',
204
                                  cls.type=='ccdeposit',
205
                                  cls.type=='btcdeposit',
206
                                  cls.type=='adjustment'
207
                                ))\
208
                        .order_by(event.Event.timestamp)\
209
                        .all()
210
        return utility.timeseries_balance_total_daily(rows)
211
212
213
    @classmethod
214
    def get_transactions_over_time_for_user(cls, user):
215
        return DBSession.query(cls.amount,
216
                               cls.type,
217
                               cls.to_account_virt_id,
218
                               cls.fr_account_virt_id,
219
                               event.Event.timestamp)\
220
                        .join(event.Event)\
221
                        .filter(event.Event.deleted==False)\
222
                        .filter(or_(
223
                                  cls.type=='purchase',
224
                                  cls.type=='cashdeposit',
225
                                  cls.type=='ccdeposit',
226
                                  cls.type=='btcdeposit',
227
                                  cls.type=='adjustment'
228
                                ))\
229
                        .filter(or_(
230
                            cls.to_account_virt_id == user.id,
231
                            cls.fr_account_virt_id == user.id,
232
                            ))\
233
                        .order_by(event.Event.timestamp)\
234
                        .all()
235
236
237
    @classmethod
238
    def get_balances_over_time_for_user(cls, user):
239
        rows = cls.get_transactions_over_time_for_user(user)
240
        # We can re-use the global balance calculation code because the query
241
        # filtered it down to only this user, only now the "global" total
242
        # positive values (r[2]) and total debt (r[1]) are just this user's
243
        # balance, so we pull out the right column at each point in time.
244
        rows = utility.timeseries_balance_total_daily(rows)
245
        rows = [(r[0],r[2]/100 if r[1]==0 else -r[1]/100) for r in rows]
246
        return rows
247
248
    @classmethod
249
    def get_days_in_debt_for_user(cls, user):
250
        rows = cls.get_transactions_over_time_for_user(user)
251
        days = utility.get_days_on_shame(user, rows)
252
        return days
253
254
255
def __get_transactions_query(self):
256
    return object_session(self).query(Transaction)\
257
            .join(event.Event)\
258
            .filter(or_(
259
                      or_(
260
                        Transaction.to_account_virt_id == self.id,
261
                        Transaction.fr_account_virt_id == self.id,
262
                        Transaction.to_account_cash_id == self.id,
263
                        Transaction.fr_account_cash_id == self.id),
264
                      and_(
265
                        or_(event.Event.type == "purchase",
266
                            event.Event.type == "deposit"),
267
                        event.Event.user_id == self.id)))\
268
            .filter(event.Event.deleted==False)\
269
            .order_by(desc(event.Event.timestamp))\
270
271
@limitable_all
272
def __get_transactions(self):
273
    return __get_transactions_query(self)
274
275
@property
276
def __transactions(self):
277
    return __get_transactions(self)
278
279
account.Account.get_transactions_query = __get_transactions_query
280
account.Account.get_transactions = __get_transactions
281
account.Account.transactions = __transactions
282
283
# This is in a stupid place due to circular input problems
284
@limitable_all
285
def __get_events(self):
286
    return object_session(self).query(event.Event)\
287
            .join(Transaction)\
288
            .filter(or_(
289
                      or_(
290
                        Transaction.to_account_virt_id == self.id,
291
                        Transaction.fr_account_virt_id == self.id,
292
                        Transaction.to_account_cash_id == self.id,
293
                        Transaction.fr_account_cash_id == self.id),
294
                      and_(
295
                        or_(event.Event.type == "purchase",
296
                            event.Event.type == "deposit"),
297
                        event.Event.user_id == self.id)))\
298
            .filter(event.Event.deleted==False)\
299
            .order_by(desc(event.Event.timestamp))
300
301
@property
302
def __events(self):
303
    return __get_events(self).all()
304
305
account.Account.get_events = __get_events
306
account.Account.events = __events
307
308
# This is in a stupid place due to circular input problems
309
@property
310
def __total_deposit_amount(self):
311
    return object_session(self).query(func.sum(Transaction.amount).label("total"))\
312
            .join(event.Event)\
313
            .filter(and_(
314
                        Transaction.to_account_virt_id == self.id,
315
                        or_(Transaction.type == 'cashdeposit',
316
                            Transaction.type == 'ccdeposit',
317
                            Transaction.type == 'btcdeposit')))\
318
            .filter(event.Event.deleted==False).one().total or Decimal(0.0)
319
account.Account.total_deposits = __total_deposit_amount
320
321
# This is in a stupid place due to circular input problems
322
@property
323
def __total_purchase_amount(self):
324
    return object_session(self).query(func.sum(Transaction.amount).label("total"))\
325
            .join(event.Event)\
326
            .filter(and_(
327
                        Transaction.fr_account_virt_id == self.id,
328
                        Transaction.type == 'purchase'))\
329
            .filter(event.Event.deleted==False).one().total or Decimal(0.0)
330
account.Account.total_purchases = __total_purchase_amount
331
332
# This is in a stupid place due to circular input problems
333
@classmethod
334
@limitable_all
335
def __get_cash_events(cls):
336
    return DBSession.query(event.Event)\
337
            .join(Transaction)\
338
            .filter(or_(
339
                      Transaction.to_account_cash_id == account.get_cash_account("chezbetty").id,
340
                      Transaction.fr_account_cash_id == account.get_cash_account("chezbetty").id))\
341
            .filter(event.Event.deleted==False)\
342
            .order_by(desc(event.Event.timestamp))
343
event.Event.get_cash_events = __get_cash_events
344
345
# This is in a stupid place due to circular input problems
346
@classmethod
347
@limitable_all
348
def __get_restock_events(cls):
349
    return DBSession.query(event.Event)\
350
            .join(Transaction)\
351
            .filter(Transaction.type == 'restock')\
352
            .filter(event.Event.deleted==False)\
353
            .order_by(desc(event.Event.timestamp))
354
event.Event.get_restock_events = __get_restock_events
355
356
# This is in a stupid place due to circular input problems
357
@classmethod
358
@limitable_all
359
def __get_emptycash_events(cls):
360
    return DBSession.query(event.Event)\
361
            .join(Transaction)\
362
            .filter(or_(
363
                      Transaction.type == 'emptycashbox',
364
                      Transaction.type == 'emptybitcoin'))\
365
            .filter(event.Event.deleted==False)\
366
            .order_by(desc(event.Event.timestamp))
367
event.Event.get_emptycash_events = __get_emptycash_events
368
369
# This is in a stupid place due to circular input problems
370
@classmethod
371
@limitable_all
372
def __get_deposit_events(cls):
373
    return DBSession.query(event.Event)\
374
            .join(Transaction)\
375
            .filter(or_(
376
                      Transaction.type == 'cashdeposit',
377
                      Transaction.type == 'ccdeposit',
378
                      Transaction.type == 'btcdeposit'))\
379
            .filter(event.Event.deleted==False)\
380
            .order_by(desc(event.Event.timestamp))
381
event.Event.get_deposit_events = __get_deposit_events
382
383
# This is in a stupid place due to circular input problems
384
@property
385
def __days_since_last_purchase(self):
386
    last_purchase = object_session(self).query(event.Event)\
387
            .join(Transaction)\
388
            .filter(Transaction.fr_account_virt_id == self.id)\
389
            .filter(event.Event.type == 'purchase')\
390
            .filter(event.Event.deleted==False)\
391
            .order_by(desc(event.Event.timestamp)).first()
392
393
    if last_purchase:
394
        diff = arrow.now() - last_purchase.timestamp
395
        return diff.days
396
    else:
397
        return None
398
user.User.days_since_last_purchase = __days_since_last_purchase
399
400
# This is in a stupid place due to circular input problems
401
@property
402
def __lifetime_fees(self):
403
    return object_session(self).query(func.sum((Purchase.amount / (1-Purchase.discount)) - Purchase.amount).label("f"))\
404
            .join(event.Event)\
405
            .filter(Purchase.fr_account_virt_id == self.id)\
406
            .filter(Purchase.discount < 0)\
407
            .filter(event.Event.type == 'purchase')\
408
            .filter(event.Event.deleted==False).one().f or Decimal(0.0)
409
user.User.lifetime_fees = __lifetime_fees
410
411
# This is in a stupid place due to circular input problems
412
@property
413
def __lifetime_discounts(self):
414
    return object_session(self).query(func.sum((Purchase.amount / (1-Purchase.discount)) - Purchase.amount).label("f"))\
415
            .join(event.Event)\
416
            .filter(Purchase.fr_account_virt_id == self.id)\
417
            .filter(Purchase.discount > 0)\
418
            .filter(event.Event.type == 'purchase')\
419
            .filter(event.Event.deleted==False).one().f or Decimal(0.0)
420
user.User.lifetime_discounts = __lifetime_discounts
421
422
423
class Purchase(Transaction):
424
    __mapper_args__ = {'polymorphic_identity': 'purchase'}
425
    discount = Column(Numeric)
426
427
    def __init__(self, event, user, discount=None):
428
        chezbetty_v = account.get_virt_account("chezbetty")
429
        Transaction.__init__(self, event, user, chezbetty_v, None, None, Decimal(0.0))
430
        self.discount = discount
431
432
433
class Deposit(Transaction):
434
    __mapper_args__ = {'polymorphic_identity': 'deposit'}
435
436
    @classmethod
437
    def deposits_by_period(cls, period, start=None, end=None):
438
        r = DBSession.query(cls.amount.label('summable'), event.Event.timestamp)\
439
                     .join(event.Event)\
440
                     .order_by(event.Event.timestamp)\
441
                     .filter(event.Event.deleted==False)
442
        if start:
443
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
444
        if end:
445
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
446
447
        return utility.group(r.all(), period)
448
449
450
class CashDeposit(Deposit):
451
    __mapper_args__ = {'polymorphic_identity': 'cashdeposit'}
452
453
    CONTENTS_THRESHOLD = 1000
454
    REPEAT_THRESHOLD = 100
455
456
    def __init__(self, event, user, amount):
457
        cashbox_c = account.get_cash_account("cashbox")
458
        prev = cashbox_c.balance
459
        Transaction.__init__(self, event, None, user, None, cashbox_c, amount)
460
        new = cashbox_c.balance
461
462
        # It feels like the model should not have all of this application
463
        # specific logic in it. What does sending an email have to do with
464
        # representing a transaction. I think this should be moved to
465
        # datalayer.py which does handle application logic.
466
        try:
467
            if prev < CashDeposit.CONTENTS_THRESHOLD and new > CashDeposit.CONTENTS_THRESHOLD:
468
                self.send_alert_email(new)
469
            elif prev > CashDeposit.CONTENTS_THRESHOLD:
470
                pr = int((prev - CashDeposit.CONTENTS_THRESHOLD) / CashDeposit.REPEAT_THRESHOLD)
471
                nr = int((new - CashDeposit.CONTENTS_THRESHOLD) / CashDeposit.REPEAT_THRESHOLD)
472
                if pr != nr:
473
                    self.send_alert_email(new, nr)
474
        except:
475
            # Some error sending email. Let's not prevent the deposit from
476
            # going through.
477
            pass
478
479
    def send_alert_email(self, amount, repeat=0):
480
        settings = get_current_registry().settings
481
482
        SUBJECT = 'Time to empty Betty. Cash box has ${}.'.format(amount)
483
        TO = '[email protected]'
484
485
        body = """
486
        <p>Betty's cash box is getting full. Time to go to the bank.</p>
487
        <p>The cash box currently contains ${}.</p>
488
        """.format(amount)
489
        if repeat > 8:
490
            body = """
491
            <p><strong>Yo! Get your shit together! That's a lot of cash lying
492
            around!</strong></p>""" + body
493
        elif repeat > 4:
494
            body = body + """
495
            <p><strong>But seriously, you should probably go empty the cashbox
496
            like, right meow.</strong></p>"""
497
498
        if 'debugging' in settings and bool(int(settings['debugging'])):
499
            SUBJECT = '[ DEBUG_MODE ] ' + SUBJECT
500
            body = """
501
            <p><em>This message was sent from a debugging session and may be
502
            safely ignored.</em></p>""" + body
503
504
        utility.send_email(TO=TO, SUBJECT=SUBJECT, body=body)
505
506
507
class CCDeposit(Deposit):
508
    __mapper_args__ = {'polymorphic_identity': 'ccdeposit'}
509
510
    stripe_id = Column(Text)
511
    cc_last4 = Column(Text)
512
513
    def __init__(self, event, user, amount, stripe_id, last4):
514
        chezbetty_c = account.get_cash_account("chezbetty")
515
        Transaction.__init__(self, event, None, user, None, chezbetty_c, amount)
516
        self.stripe_id = stripe_id
517
        self.cc_last4 = last4
518
519
520
class BTCDeposit(Deposit):
521
    __mapper_args__ = {'polymorphic_identity': 'btcdeposit'}
522
523
    btctransaction = Column(String(64))
524
    address        = Column(String(64))
525
    amount_btc     = Column(Numeric, nullable=True)
526
527
    def __init__(self, event, user, amount, btctransaction, address, amount_btc):
528
        btcbox_c = account.get_cash_account("btcbox")
529
        Transaction.__init__(self, event, None, user, None, btcbox_c, amount)
530
        self.btctransaction = btctransaction
531
        self.address = address
532
        self.amount_btc = amount_btc
533
534
    def __getattr__(self, name):
535
        if name == 'img':
536
            return utility.string_to_qrcode(self.btctransaction)
537
        else:
538
            raise AttributeError
539
540
    @classmethod
541
    def from_address(cls, address):
542
        return DBSession.query(cls).join(event.Event)\
543
                        .filter(cls.address == address)\
544
                        .filter(event.Event.deleted == False).one()
545
546
547
class Adjustment(Transaction):
548
    __mapper_args__ = {'polymorphic_identity': 'adjustment'}
549
550
    def __init__(self, event, user, amount):
551
        chezbetty_v = account.get_virt_account("chezbetty")
552
        Transaction.__init__(self, event, chezbetty_v, user, None, None, amount)
553
554
555
class Restock(Transaction):
556
    __mapper_args__ = {'polymorphic_identity': 'restock'}
557
558
    # Additional cost that should get distributed over the entire restock
559
    amount_restock_cost = Column(Numeric, nullable=True)
560
561
    def __init__(self, event, global_cost):
562
        chezbetty_v = account.get_virt_account("chezbetty")
563
        chezbetty_c = account.get_cash_account("chezbetty")
564
        Transaction.__init__(self, event, chezbetty_v, None, chezbetty_c, None, Decimal(0.0))
565
        self.amount_restock_cost = global_cost
566
567
568
class Inventory(Transaction):
569
    __mapper_args__ = {'polymorphic_identity': 'inventory'}
570
    def __init__(self, event):
571
        chezbetty_v = account.get_virt_account("chezbetty")
572
        Transaction.__init__(self, event, chezbetty_v, None, None, None, Decimal(0.0))
573
574
575
class EmptyCashBox(Transaction):
576
    __mapper_args__ = {'polymorphic_identity': 'emptycashbox'}
577
    def __init__(self, event, amount):
578
        cashbox_c = account.get_cash_account("cashbox")
579
        chezbetty_c = account.get_cash_account("chezbetty")
580
        Transaction.__init__(self, event, None, None, cashbox_c, chezbetty_c, amount)
581
582
583
class EmptyBitcoin(Transaction):
584
    __mapper_args__ = {'polymorphic_identity': 'emptybitcoin'}
585
    def __init__(self, event, amount):
586
        btnbox_c = account.get_cash_account("btcbox")
587
        chezbetty_c = account.get_cash_account("chezbetty")
588
        Transaction.__init__(self, event, None, None, btnbox_c, chezbetty_c, amount)
589
590
591
class Lost(Transaction):
592
    __mapper_args__ = {'polymorphic_identity': 'lost'}
593
    def __init__(self, event, source_acct, amount):
594
        Transaction.__init__(self, event, None, None, source_acct, None, amount)
595
596
597
class Found(Transaction):
598
    __mapper_args__ = {'polymorphic_identity': 'found'}
599
    def __init__(self, event, dest_acct, amount):
600
        Transaction.__init__(self, event, None, None, None, dest_acct, amount)
601
602
603
class Donation(Transaction):
604
    __mapper_args__ = {'polymorphic_identity': 'donation'}
605
    def __init__(self, event, amount):
606
        chezbetty_v = account.get_virt_account("chezbetty")
607
        chezbetty_c = account.get_cash_account("chezbetty")
608
        Transaction.__init__(self, event, None, chezbetty_v, None, chezbetty_c, amount)
609
610
611
class Withdrawal(Transaction):
612
    __mapper_args__ = {'polymorphic_identity': 'withdrawal'}
613
    def __init__(self, event, amount):
614
        chezbetty_v = account.get_virt_account("chezbetty")
615
        chezbetty_c = account.get_cash_account("chezbetty")
616
        Transaction.__init__(self, event, chezbetty_v, None, chezbetty_c, None, amount)
617
618
619
################################################################################
620
## SUB TRANSACTIONS
621
################################################################################
622
623
class SubTransaction(Base):
624
    __tablename__   = "subtransactions"
625
626
    id              = Column(Integer, primary_key=True, nullable=False)
627
    transaction_id  = Column(Integer, ForeignKey("transactions.id"), nullable=False)
628
    amount          = Column(Numeric, nullable=False)
629
    type            = Column(Enum("purchaselineitem", "restocklineitem",
630
                                  "restocklinebox", "inventorylineitem",
631
                                  name="subtransaction_type"), nullable=False)
632
    item_id         = Column(Integer, ForeignKey("items.id"), nullable=True)
633
    quantity        = Column(Integer, nullable=False)
634
    wholesale       = Column(Numeric, nullable=False)
635
636
    # For restocks
637
    coupon_amount   = Column(Numeric, nullable=True) # Amount of discount on the item
638
    sales_tax       = Column(Boolean, nullable=True) # Whether sales tax was charged
639
    bottle_deposit  = Column(Boolean, nullable=True) # Whether there was a bottle deposit
640
641
    transaction     = relationship(Transaction, backref="subtransactions", cascade="all")
642
    item            = relationship(item.Item, backref="subtransactions")
643
644
    __mapper_args__ = {'polymorphic_on': type}
645
646
    def __init__(self, transaction, amount, item_id, quantity, wholesale):
647
        self.transaction_id = transaction.id
648
        self.amount = amount
649
        self.item_id = item_id
650
        self.quantity = quantity
651
        self.wholesale = wholesale
652
653
    def __getattr__(self, name):
654
        if name == 'deleted':
655
            return self.transaction.event.deleted
656
        else:
657
            raise AttributeError
658
659
    @classmethod
660
    @limitable_all
661
    def all_item(cls, id):
662
        return DBSession.query(cls)\
663
                        .join(Transaction)\
664
                        .join(event.Event)\
665
                        .filter(cls.item_id == id)\
666
                        .filter(event.Event.deleted==False)\
667
                        .order_by(desc(event.Event.timestamp))
668
669
    @classmethod
670
    @limitable_all
671
    def all_item_purchases(cls, id):
672
        return DBSession.query(cls)\
673
                        .join(Transaction)\
674
                        .join(event.Event)\
675
                        .filter(cls.item_id == id)\
676
                        .filter(event.Event.deleted==False)\
677
                        .filter(event.Event.type=="purchase")\
678
                        .order_by(desc(event.Event.timestamp))
679
680
    @classmethod
681
    @limitable_all
682
    def all_item_events(cls, id):
683
        return DBSession.query(cls)\
684
                        .join(Transaction)\
685
                        .join(event.Event)\
686
                        .filter(cls.item_id == id)\
687
                        .filter(event.Event.deleted==False)\
688
                        .filter(or_(event.Event.type=="inventory", event.Event.type =="restock"))\
689
                        .order_by(desc(event.Event.timestamp))
690
691
    @classmethod
692
    @limitable_all
693
    def all(cls, trans_type=None):
694
        if not trans_type:
695
            return DBSession.query(cls)\
696
                            .join(Transaction)\
697
                            .join(event.Event)\
698
                            .filter(event.Event.deleted==False)\
699
                            .order_by(desc(event.Event.timestamp))
700
        else:
701
            return DBSession.query(cls)\
702
                            .join(Transaction)\
703
                            .join(event.Event)\
704
                            .filter(cls.type==trans_type)\
705
                            .filter(event.Event.deleted==False)\
706
                            .order_by(desc(event.Event.timestamp))
707
708
class PurchaseLineItem(SubTransaction):
709
    __mapper_args__ = {'polymorphic_identity': 'purchaselineitem'}
710
    price           = Column(Numeric)
711
    def __init__(self, transaction, amount, item, quantity, price, wholesale):
712
        SubTransaction.__init__(self, transaction, amount, item.id, quantity, wholesale)
713
        self.price = price
714
715
    @classmethod
716
    def quantity_by_period(cls, period, start=None, end=None):
717
        r = DBSession.query(cls.quantity.label('summable'), event.Event.timestamp)\
718
                     .join(Transaction)\
719
                     .join(event.Event)\
720
                     .filter(event.Event.deleted==False)\
721
                     .order_by(event.Event.timestamp)
722
        if start:
723
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
724
        if end:
725
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
726
        return utility.group(r.all(), period)
727
728
    @classmethod
729
    def virtual_revenue_by_period(cls, period, start=None, end=None):
730
        r = DBSession.query(cls.amount.label('summable'), event.Event.timestamp)\
731
                     .join(Transaction)\
732
                     .join(event.Event)\
733
                     .filter(event.Event.deleted==False)\
734
                     .order_by(event.Event.timestamp)
735
        if start:
736
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
737
        if end:
738
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
739
        return utility.group(r.all(), period)
740
741
    @classmethod
742
    def profit_on_sales(cls, start=None, end=None):
743
        r = DBSession.query(func.sum(cls.amount-(cls.wholesale*cls.quantity)).label('p'))\
744
                        .join(Transaction)\
745
                        .join(event.Event)\
746
                        .filter(event.Event.deleted==False)
747
        if start:
748
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
749
        if end:
750
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
751
752
        return r.one().p or Decimal(0.0)
753
754
    @classmethod
755
    def item_sale_quantities(cls, item_id):
756
        return DBSession.query(cls, event.Event)\
757
                        .join(Transaction)\
758
                        .join(event.Event)\
759
                        .filter(event.Event.deleted==False)\
760
                        .filter(cls.item_id==int(item_id))\
761
                        .order_by(event.Event.timestamp).all()
762
763
764
# This is slowww:
765
# @property
766
# def __number_sold(self):
767
#     return object_session(self).query(func.sum(PurchaseLineItem.quantity).label('c'))\
768
#                                .join(Transaction)\
769
#                                .join(event.Event)\
770
#                                .filter(PurchaseLineItem.item_id==self.id)\
771
#                                .filter(event.Event.deleted==False).one().c
772
# item.Item.number_sold = __number_sold
773
774
775
class RestockLineItem(SubTransaction):
776
    __mapper_args__ = {'polymorphic_identity': 'restocklineitem'}
777
    def __init__(self,
778
                 transaction,
779
                 amount,
780
                 item,
781
                 quantity,
782
                 wholesale,
783
                 coupon,
784
                 sales_tax,
785
                 bottle_deposit):
786
        SubTransaction.__init__(self, transaction, amount, item.id, quantity, wholesale)
787
        self.coupon_amount = coupon
788
        self.sales_tax = sales_tax
789
        self.bottle_deposit = bottle_deposit
790
791
792
class RestockLineBox(SubTransaction):
793
    __mapper_args__ = {'polymorphic_identity': 'restocklinebox'}
794
    box_id          = Column(Integer, ForeignKey("boxes.id"), nullable=True)
795
796
    box             = relationship(box.Box, backref="subtransactions")
797
798
    def __init__(self,
799
                 transaction,
800
                 amount,
801
                 box,
802
                 quantity,
803
                 wholesale,
804
                 coupon,
805
                 sales_tax,
806
                 bottle_deposit):
807
        self.transaction_id = transaction.id
808
        self.amount = amount
809
        self.box_id = box.id
810
        self.quantity = quantity
811
        self.wholesale = wholesale
812
        self.coupon_amount = coupon
813
        self.sales_tax = sales_tax
814
        self.bottle_deposit = bottle_deposit
815
816
817
class InventoryLineItem(SubTransaction):
818
    __mapper_args__    = {'polymorphic_identity': 'inventorylineitem'}
819
    quantity_predicted = synonym(SubTransaction.quantity)
820
    quantity_counted   = Column(Integer)
821
822
    def __init__(self, transaction, amount, item, quantity_predicted, quantity_counted, wholesale):
823
        SubTransaction.__init__(self, transaction, amount, item.id, quantity_predicted, wholesale)
824
        self.quantity_counted = quantity_counted
825
826
827
828
################################################################################
829
## SUBSUB TRANSACTIONS
830
################################################################################
831
832
# This is for tracking which items were in which boxes when we restocked
833
834
class SubSubTransaction(Base):
835
    __tablename__      = "subsubtransactions"
836
837
    id                 = Column(Integer, primary_key=True, nullable=False)
838
    subtransaction_id  = Column(Integer, ForeignKey("subtransactions.id"), nullable=False)
839
    type               = Column(Enum("restocklineboxitem",
840
                                     name="subsubtransaction_type"), nullable=False)
841
    item_id            = Column(Integer, ForeignKey("items.id"), nullable=True)
842
    quantity           = Column(Integer, nullable=False)
843
844
    subtransaction     = relationship(SubTransaction, backref="subsubtransactions", cascade="all")
845
    item               = relationship(item.Item, backref="subsubtransactions")
846
847
    __mapper_args__    = {'polymorphic_on': type}
848
849
    def __init__(self, subtransaction, item_id, quantity):
850
        self.subtransaction_id = subtransaction.id
851
        self.item_id = item_id
852
        self.quantity = quantity
853
854
    def __getattr__(self, name):
855
        if name == 'deleted':
856
            return self.subtransaction.transaction.event.deleted
857
        else:
858
            raise AttributeError
859
860
    @classmethod
861
    @limitable_all
862
    def all_item(cls, item_id):
863
        return DBSession.query(cls)\
864
                        .join(SubTransaction)\
865
                        .join(Transaction)\
866
                        .join(event.Event)\
867
                        .filter(cls.item_id == item_id)\
868
                        .filter(event.Event.deleted==False)\
869
                        .order_by(cls.id)
870
871
872
class RestockLineBoxItem(SubSubTransaction):
873
    __mapper_args__ = {'polymorphic_identity': 'restocklineboxitem'}
874
    def __init__(self, subtransaction, item, quantity):
875
        SubSubTransaction.__init__(self, subtransaction, item.id, quantity)
876
877
878
879