1 | class Transaction(models.Model):
|
---|
2 | """
|
---|
3 | Financial tracking entry. Either expense, income or transfer between two
|
---|
4 | accounts.
|
---|
5 |
|
---|
6 | user - owner of the transaction
|
---|
7 | date - date when transaction occurred
|
---|
8 | type - one of 3: 'EXPENSES', 'INCOME' or 'TRANSFER'
|
---|
9 | amount - amount of money in transaction
|
---|
10 | currency - currency that was used for the transaction
|
---|
11 | amount_primary - amount in user's primary currency
|
---|
12 | category and sub_category - ids for category and sub_category if the are
|
---|
13 | present
|
---|
14 | account - account that was used in transaction
|
---|
15 | source_account - if transfer, money go from it to `account`
|
---|
16 | tags - another way to categorize transactions. Transaction may only have
|
---|
17 | one category but as many tags as needed
|
---|
18 | project - yet another way to categorize transactions and share them (TODO)
|
---|
19 | note - optional user's note
|
---|
20 | recurrence - repeat config for repeating transactions
|
---|
21 | recurrence_parent - link to original transaction for repeating
|
---|
22 | transactions
|
---|
23 | exchange_rate - json data
|
---|
24 | updated_at - when transaction was edited last time
|
---|
25 | created_at - when transaction was created
|
---|
26 | """
|
---|
27 |
|
---|
28 | EXPENSES, INCOME, TRANSFER = 'EXPENSES', 'INCOME', 'TRANSFER'
|
---|
29 | TRANSACTION_TYPE_CHOICES = (
|
---|
30 | (EXPENSES, EXPENSES),
|
---|
31 | (INCOME, INCOME),
|
---|
32 | (TRANSFER, TRANSFER),
|
---|
33 | )
|
---|
34 |
|
---|
35 | user = models.ForeignKey(User)
|
---|
36 | date = models.DateTimeField(db_index=True)
|
---|
37 | type = models.CharField(
|
---|
38 | choices=TRANSACTION_TYPE_CHOICES,
|
---|
39 | default=EXPENSES,
|
---|
40 | max_length=10,
|
---|
41 | db_index=True
|
---|
42 | )
|
---|
43 | amount = models.DecimalField(max_digits=12, decimal_places=2)
|
---|
44 | currency = models.ForeignKey(Currency)
|
---|
45 | amount_primary = models.DecimalField(max_digits=12, decimal_places=2,
|
---|
46 | null=True, blank=True)
|
---|
47 | category = models.ForeignKey(
|
---|
48 | Category,
|
---|
49 | null=True,
|
---|
50 | blank=True,
|
---|
51 | on_delete=models.SET_NULL
|
---|
52 | )
|
---|
53 | sub_category = models.ForeignKey(SubCategory, null=True, blank=True,
|
---|
54 | on_delete=models.SET_NULL)
|
---|
55 | source_account = models.ForeignKey(
|
---|
56 | Account,
|
---|
57 | null=True,
|
---|
58 | blank=True,
|
---|
59 | related_name='source_transactions',
|
---|
60 | on_delete=models.SET_NULL
|
---|
61 | )
|
---|
62 | account = models.ForeignKey(Account, related_name='account_transactions')
|
---|
63 | tags = models.ManyToManyField('Tag', null=True, blank=True,
|
---|
64 | related_name='transactions')
|
---|
65 | project = models.ForeignKey(Project, blank=True, null=True)
|
---|
66 | note = models.TextField(null=True, blank=True)
|
---|
67 | recurrence = models.ForeignKey('Recurrence', null=True, blank=True,
|
---|
68 | on_delete=models.SET_NULL)
|
---|
69 | recurrence_parent = models.ForeignKey(
|
---|
70 | 'Transaction',
|
---|
71 | null=True,
|
---|
72 | blank=True,
|
---|
73 | on_delete=models.SET_NULL
|
---|
74 | )
|
---|
75 | exchange_rate = models.ForeignKey('ExchangeRate')
|
---|
76 | updated_at = models.DateTimeField(
|
---|
77 | auto_now=True,
|
---|
78 | null=True,
|
---|
79 | blank=True,
|
---|
80 | db_index=True
|
---|
81 | )
|
---|
82 | created_at = models.DateTimeField(
|
---|
83 | auto_now_add=True,
|
---|
84 | blank=True,
|
---|
85 | null=True,
|
---|
86 | db_index=True
|
---|
87 | )
|
---|
88 |
|
---|
89 | _original_amount = None
|
---|
90 | _original_currency = None
|
---|
91 |
|
---|
92 | class Meta:
|
---|
93 | ordering = ('-date', '-id')
|
---|
94 |
|
---|
95 | def __init__(self, *args, **kwargs):
|
---|
96 | super().__init__(*args, **kwargs)
|
---|
97 | self._original_amount = self.amount
|
---|
98 | self._original_currency = getattr(self, 'currency', None)
|
---|
99 |
|
---|
100 | def save(self, *args, **kwargs):
|
---|
101 | """
|
---|
102 | Check if amount or currency have changed and recalculate
|
---|
103 | primary_amount if that's the case.
|
---|
104 |
|
---|
105 | Check if sub_category and category fields don't match and
|
---|
106 | fix it (with priority to sub_category)
|
---|
107 | """
|
---|
108 | if (
|
---|
109 | self.amount_primary is None or
|
---|
110 | self.amount != self._original_amount or
|
---|
111 | self.currency != self._original_currency
|
---|
112 | ):
|
---|
113 | self.amount_primary = self.primary_amount()
|
---|
114 |
|
---|
115 | if self.sub_category is not None:
|
---|
116 | if self.category != self.sub_category.category:
|
---|
117 | self.category = self.sub_category.category
|
---|
118 |
|
---|
119 | super().save(*args, **kwargs)
|
---|
120 | self._original_amount = self.amount
|
---|
121 | self._original_currency = self.currency
|
---|
122 |
|
---|
123 | def clean(self):
|
---|
124 | """
|
---|
125 | Override Transaction's clean method to do validation regarding
|
---|
126 | source_account
|
---|
127 | """
|
---|
128 | super(Transaction, self).clean()
|
---|
129 |
|
---|
130 | if self.type == Transaction.TRANSFER and self.source_account is None:
|
---|
131 | raise exceptions.ValidationError({
|
---|
132 | 'source_account': ['This has to be set when type == ' +
|
---|
133 | str(self.TRANSFER)]
|
---|
134 | })
|
---|
135 | elif self.source_account == self.account:
|
---|
136 | raise exceptions.ValidationError({
|
---|
137 | 'source_account': ['This cannot be the same as account']
|
---|
138 | })
|
---|
139 |
|
---|
140 | # Check that all items associated with the transaction belong to the
|
---|
141 | # user associated aith the transaction
|
---|
142 | if self.sub_category and self.sub_category.user != self.user:
|
---|
143 | raise exceptions.ValidationError({
|
---|
144 | 'sub_category': ['belongs to different user']
|
---|
145 | })
|
---|
146 |
|
---|
147 | if self.source_account and self.source_account.user != self.user:
|
---|
148 | raise exceptions.ValidationError({
|
---|
149 | 'source_account': ['belongs to different user']
|
---|
150 | })
|
---|
151 |
|
---|
152 | if self.account and self.account.user != self.user:
|
---|
153 | raise exceptions.ValidationError({
|
---|
154 | 'account': ['belongs to different user']
|
---|
155 | })
|
---|
156 |
|
---|
157 | if self.project and self.project.user != self.user:
|
---|
158 | raise exceptions.ValidationError({
|
---|
159 | 'project': ['belongs to different user']
|
---|
160 | })
|
---|
161 |
|
---|
162 | if self.category and self.category.user != self.user:
|
---|
163 | raise exceptions.ValidationError({
|
---|
164 | 'category': ['belongs to different user']
|
---|
165 | })
|
---|
166 |
|
---|
167 | # Set the project to standard project if
|
---|
168 | # user does not explicitly set it
|
---|
169 | if not self.project:
|
---|
170 | project = Project.objects.filter(user=self.user,
|
---|
171 | default=True).first()
|
---|
172 | if not project:
|
---|
173 | raise exceptions.ValidationError('Missing default project')
|
---|
174 |
|
---|
175 | self.project = project
|
---|
176 |
|
---|
177 | def primary_amount(self):
|
---|
178 | """
|
---|
179 | Return the user's amount in his/her primary currency
|
---|
180 | """
|
---|
181 | if self.user.preferences.primary_currency is None:
|
---|
182 | return decimal.Decimal(0)
|
---|
183 |
|
---|
184 | if self.currency.code == self.user.preferences.primary_currency.code:
|
---|
185 | return decimal.Decimal(self.amount)
|
---|
186 |
|
---|
187 | current_rate = decimal.Decimal(
|
---|
188 | self.exchange_rate.rates[self.currency.code]
|
---|
189 | )
|
---|
190 |
|
---|
191 | amount = decimal.Decimal(self.amount)
|
---|
192 |
|
---|
193 | current_usd_amount = amount/current_rate
|
---|
194 |
|
---|
195 | currency_code = self.user.preferences.primary_currency.code
|
---|
196 |
|
---|
197 | return current_usd_amount * decimal.Decimal(
|
---|
198 | self.exchange_rate.rates[currency_code]
|
---|
199 | )
|
---|
200 |
|
---|
201 | def primary_currency(self):
|
---|
202 | """
|
---|
203 | Return the user's primary currency
|
---|
204 | """
|
---|
205 | return self.user.preferences.primary_currency
|
---|
206 |
|
---|
207 | def __str__(self):
|
---|
208 | return self.user.get_full_name() + ' : ' + str(self.amount)
|
---|
209 |
|
---|
210 | @property
|
---|
211 | def is_income(self, ):
|
---|
212 | """
|
---|
213 | Is this transaction an income?
|
---|
214 | """
|
---|
215 | return self.type == self.INCOME
|
---|
216 |
|
---|
217 | @property
|
---|
218 | def is_expense(self, ):
|
---|
219 | """
|
---|
220 | Is this an expense?
|
---|
221 | """
|
---|
222 | return self.type == self.EXPENSES
|
---|
223 |
|
---|
224 | @property
|
---|
225 | def is_transfer(self, ):
|
---|
226 | """
|
---|
227 | Is this transaction a transfer?
|
---|
228 | """
|
---|
229 | return self.type == self.TRANSFER
|
---|