1
|
|
|
'''Parser for Transaction Detail Report files |
2
|
|
|
|
3
|
|
|
See https://www.paypalobjects.com/webstatic/en_US/developer/docs/pdf/PP_LRD_Gen_TransactionDetailReport.pdf |
4
|
|
|
''' |
5
|
|
|
|
6
|
|
|
import re |
7
|
|
|
|
8
|
|
|
from process.logging import Logger as log |
9
|
|
|
import process.globals |
10
|
|
|
import queue.redis_wrap |
11
|
|
|
import ppreport |
12
|
|
|
|
13
|
|
|
import civicrm.civicrm |
14
|
|
|
import paypal_api |
15
|
|
|
|
16
|
|
|
|
17
|
|
|
class TrrFile(object): |
18
|
|
|
VERSION = [4, 8] |
19
|
|
|
redis = None |
20
|
|
|
# FIXME: these are version 8 headers, we would fail on multi-part v4 files... |
21
|
|
|
column_headers = [ |
22
|
|
|
"Column Type", |
23
|
|
|
"Transaction ID", |
24
|
|
|
"Invoice ID", |
25
|
|
|
"PayPal Reference ID", |
26
|
|
|
"PayPal Reference ID Type", |
27
|
|
|
"Transaction Event Code", |
28
|
|
|
"Transaction Initiation Date", |
29
|
|
|
"Transaction Completion Date", |
30
|
|
|
"Transaction Debit or Credit", |
31
|
|
|
"Gross Transaction Amount", |
32
|
|
|
"Gross Transaction Currency", |
33
|
|
|
"Fee Debit or Credit", |
34
|
|
|
"Fee Amount", |
35
|
|
|
"Fee Currency", |
36
|
|
|
"Transactional Status", |
37
|
|
|
"Insurance Amount", |
38
|
|
|
"Sales Tax Amount", |
39
|
|
|
"Shipping Amount", |
40
|
|
|
"Transaction Subject", |
41
|
|
|
"Transaction Note", |
42
|
|
|
"Payer's Account ID", |
43
|
|
|
"Payer Address Status", |
44
|
|
|
"Item Name", |
45
|
|
|
"Item ID", |
46
|
|
|
"Option 1 Name", |
47
|
|
|
"Option 1 Value", |
48
|
|
|
"Option 2 Name", |
49
|
|
|
"Option 2 Value", |
50
|
|
|
"Auction Site", |
51
|
|
|
"Auction Buyer ID", |
52
|
|
|
"Auction Closing Date", |
53
|
|
|
"Shipping Address Line1", |
54
|
|
|
"Shipping Address Line2", |
55
|
|
|
"Shipping Address City", |
56
|
|
|
"Shipping Address State", |
57
|
|
|
"Shipping Address Zip", |
58
|
|
|
"Shipping Address Country", |
59
|
|
|
"Shipping Method", |
60
|
|
|
"Custom Field", |
61
|
|
|
"Billing Address Line1", |
62
|
|
|
"Billing Address Line2", |
63
|
|
|
"Billing Address City", |
64
|
|
|
"Billing Address State", |
65
|
|
|
"Billing Address Zip", |
66
|
|
|
"Billing Address Country", |
67
|
|
|
"Consumer ID", |
68
|
|
|
"First Name", |
69
|
|
|
"Last Name", |
70
|
|
|
"Consumer Business Name", |
71
|
|
|
"Card Type", |
72
|
|
|
"Payment Source", |
73
|
|
|
"Shipping Name", |
74
|
|
|
"Authorization Review Status", |
75
|
|
|
"Protection Eligibility", |
76
|
|
|
"Payment Tracking ID", |
77
|
|
|
] |
78
|
|
|
|
79
|
|
|
@staticmethod |
80
|
|
|
def handle(path): |
81
|
|
|
obj = TrrFile(path) |
82
|
|
|
obj.parse() |
83
|
|
|
|
84
|
|
|
def __init__(self, path): |
85
|
|
|
self.path = path |
86
|
|
|
self.config = process.globals.get_config() |
87
|
|
|
self.crm = civicrm.civicrm.Civicrm(self.config.civicrm_db) |
88
|
|
|
|
89
|
|
|
def parse(self): |
90
|
|
|
# FIXME: encapsulation issues |
91
|
|
|
ppreport.read(self.path, self.VERSION, self.parse_line, self.column_headers) |
92
|
|
|
|
93
|
|
|
def parse_line(self, row): |
94
|
|
|
if row['Billing Address Line1']: |
95
|
|
|
addr_prefix = 'Billing Address ' |
96
|
|
|
else: |
97
|
|
|
addr_prefix = 'Shipping Address ' |
98
|
|
|
|
99
|
|
|
# FIXME: Accept an empty or malformed date, or keep the exception? |
100
|
|
|
out = { |
101
|
|
|
'gateway_txn_id': row['Transaction ID'], |
102
|
|
|
'date': ppreport.parse_date(row['Transaction Initiation Date']), |
103
|
|
|
'settled_date': ppreport.parse_date(row['Transaction Completion Date']), |
104
|
|
|
'gross': float(row['Gross Transaction Amount']) / 100.0, |
105
|
|
|
'currency': row['Gross Transaction Currency'], |
106
|
|
|
'gateway_status': row['Transactional Status'], |
107
|
|
|
'note': row['Transaction Note'], |
108
|
|
|
'email': row['Payer\'s Account ID'], |
109
|
|
|
|
110
|
|
|
'street_address': row[addr_prefix + 'Line1'], |
111
|
|
|
'supplemental_address_1': row[addr_prefix + 'Line2'], |
112
|
|
|
'city': row[addr_prefix + 'City'], |
113
|
|
|
'state_province': row[addr_prefix + 'State'], |
114
|
|
|
'postal_code': row[addr_prefix + 'Zip'], |
115
|
|
|
'country': row[addr_prefix + 'Country'], |
116
|
|
|
} |
117
|
|
|
|
118
|
|
|
# FIXME: This is weasly, see that we're also sending the raw payment |
119
|
|
|
# source value as payment_method. |
120
|
|
|
if row['Payment Source'] == 'Express Checkout': |
121
|
|
|
out['gateway'] = 'paypal_ec' |
122
|
|
|
else: |
123
|
|
|
out['gateway'] = 'paypal' |
124
|
|
|
|
125
|
|
|
if row['Fee Amount']: |
126
|
|
|
out['fee'] = float(row['Fee Amount']) / 100.0 |
127
|
|
|
|
128
|
|
|
if row['Fee Currency'] and row['Gross Transaction Currency'] != row['Fee Currency']: |
129
|
|
|
raise RuntimeError("Failed to import because multiple currencies for one transaction is not handled.") |
130
|
|
|
|
131
|
|
|
if 'First Name' in row: |
132
|
|
|
out['first_name'] = row['First Name'] |
133
|
|
|
|
134
|
|
|
if 'Last Name' in row: |
135
|
|
|
out['last_name'] = row['Last Name'] |
136
|
|
|
|
137
|
|
|
if 'Payment Source' in row: |
138
|
|
|
out['payment_method'] = row['Payment Source'] |
139
|
|
|
|
140
|
|
|
if 'Card Type' in row: |
141
|
|
|
out['payment_submethod'] = row['Card Type'] |
142
|
|
|
|
143
|
|
|
if row['PayPal Reference ID Type'] == 'SUB': |
144
|
|
|
out['subscr_id'] = row['PayPal Reference ID'] |
145
|
|
|
|
146
|
|
|
# Look in all the places we might have stuck a ct_id |
147
|
|
|
if re.search('^[0-9]+$', row['Transaction Subject']): |
148
|
|
|
out['contribution_tracking_id'] = row['Transaction Subject'] |
149
|
|
|
elif row['Custom Field']: |
150
|
|
|
out['contribution_tracking_id'] = row['Custom Field'] |
151
|
|
|
elif row['Invoice ID']: |
152
|
|
|
# Here it can be the ct_id.attempt format |
153
|
|
|
out['contribution_tracking_id'] = row['Invoice ID'].split('.')[0] |
154
|
|
|
|
155
|
|
|
if out['contribution_tracking_id']: |
156
|
|
|
out['order_id'] = out['contribution_tracking_id'] |
157
|
|
|
|
158
|
|
|
event_type = row['Transaction Event Code'][0:3] |
159
|
|
|
|
160
|
|
|
queue = None |
161
|
|
|
if event_type in ('T00', 'T03', 'T05', 'T07', 'T22'): |
162
|
|
|
if row['Transaction Event Code'] == 'T0002': |
163
|
|
|
queue = 'recurring' |
164
|
|
|
out['txn_type'] = 'subscr_payment' |
165
|
|
|
if 'subscr_id' not in out or not out['subscr_id']: |
166
|
|
|
raise Exception('Missing field subscr_id') |
167
|
|
|
elif row['Transaction Debit or Credit'] == 'DR': |
168
|
|
|
# sic: double-space is coming from the upstream |
169
|
|
|
log.info("-Debit\t{id}\t{date}\tPayment to".format(id=out['gateway_txn_id'], date=out['date'])) |
170
|
|
|
# This payment is from us! Do not send to the CRM. |
171
|
|
|
return |
172
|
|
|
else: |
173
|
|
|
queue = 'donations' |
174
|
|
|
elif event_type in ('T11', 'T12'): |
175
|
|
|
out['gateway_refund_id'] = out['gateway_txn_id'] |
176
|
|
|
out['gross_currency'] = out['currency'] |
177
|
|
|
|
178
|
|
|
if row['PayPal Reference ID Type'] == 'TXN': |
179
|
|
|
out['gateway_parent_id'] = row['PayPal Reference ID'] |
180
|
|
|
|
181
|
|
|
if row['Transaction Event Code'] == 'T1106': |
182
|
|
|
out['type'] = 'reversal' |
183
|
|
|
elif row['Transaction Event Code'] == 'T1107': |
184
|
|
|
out['type'] = 'refund' |
185
|
|
|
elif row['Transaction Event Code'] == 'T1201': |
186
|
|
|
out['type'] = 'chargeback' |
187
|
|
|
else: |
188
|
|
|
log.info("-Unknown\t{id}\t{date}\t(Refundish type {type})".format(id=out['gateway_txn_id'], date=out['date'], type=row['Transaction Event Code'])) |
189
|
|
|
return |
190
|
|
|
|
191
|
|
|
queue = 'refund' |
192
|
|
|
|
193
|
|
|
if not queue: |
194
|
|
|
log.info("-Unknown\t{id}\t{date}\t(Type {type})".format(id=out['gateway_txn_id'], date=out['date'], type=event_type)) |
195
|
|
|
return |
196
|
|
|
|
197
|
|
|
if queue == 'donations' and self.crm.transaction_exists(gateway_txn_id=out['gateway_txn_id'], gateway=out['gateway']): |
198
|
|
|
log.info("-Duplicate\t{id}\t{date}\t{type}".format(id=out['gateway_txn_id'], date=row['Transaction Initiation Date'], type=queue)) |
199
|
|
|
return |
200
|
|
|
|
201
|
|
|
if queue == 'refund' and self.crm.transaction_refunded(gateway_txn_id=out['gateway_parent_id'], gateway=out['gateway']): |
202
|
|
|
log.info("-Duplicate\t{id}\t{date}\t{type}".format(id=out['gateway_txn_id'], date=row['Transaction Initiation Date'], type=queue)) |
203
|
|
|
return |
204
|
|
|
|
205
|
|
|
if 'last_name' not in out and queue != 'refund': |
206
|
|
|
out['first_name'], out['last_name'] = paypal_api.PaypalApiClassic().fetch_donor_name(out['gateway_txn_id']) |
207
|
|
|
|
208
|
|
|
if self.config.no_thankyou: |
209
|
|
|
out['thankyou_date'] = 0 |
210
|
|
|
|
211
|
|
|
log.info("+Sending\t{id}\t{date}\t{type}".format(id=out['gateway_txn_id'], date=row['Transaction Initiation Date'], type=queue)) |
212
|
|
|
self.send(queue, out) |
213
|
|
|
|
214
|
|
|
def send(self, queue_name, msg): |
215
|
|
|
if not self.redis: |
216
|
|
|
self.redis = queue.redis_wrap.Redis() |
217
|
|
|
|
218
|
|
|
self.redis.send(queue_name, msg) |
219
|
|
|
|