Issues (24)

chezbetty/models/transaction.py (6 issues)

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