From 0e1aebe60b5d185e7f212d2ccf0519d76e134688 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 24 Feb 2026 04:21:05 -0500 Subject: [PATCH] feat: add Pending status for delivery/technician tasks - New 'pending' status allows tasks to be created without a schedule, acting as a queue for unscheduled work that gets assigned later - Pending group appears in the Delivery Map sidebar with amber color - Other modules can create tasks in pending state for scheduling - scheduled_date no longer required (null for pending tasks) - New Pending Tasks menu item under Field Service - Pending filter added to search view Co-authored-by: Cursor --- fusion_claims/models/technician_task.py | 5 +- .../static/src/js/fusion_task_map_view.js | 23 +- .../static/src/xml/fusion_task_map_view.xml | 1 + fusion_claims/views/technician_task_views.xml | 18 +- fusion_poynt/__init__.py | 17 + fusion_poynt/__manifest__.py | 29 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 635 bytes .../__pycache__/const.cpython-312.pyc | Bin 0 -> 1530 bytes .../__pycache__/utils.cpython-312.pyc | Bin 0 -> 8833 bytes fusion_poynt/const.py | 84 +++ fusion_poynt/controllers/__init__.py | 3 + fusion_poynt/controllers/main.py | 518 ++++++++++++++++++ fusion_poynt/data/payment_provider_data.xml | 12 + fusion_poynt/models/__init__.py | 6 + fusion_poynt/models/payment_provider.py | 377 +++++++++++++ fusion_poynt/models/payment_token.py | 55 ++ fusion_poynt/models/payment_transaction.py | 386 +++++++++++++ fusion_poynt/models/poynt_terminal.py | 202 +++++++ fusion_poynt/security/ir.model.access.csv | 3 + fusion_poynt/static/description/icon.png | Bin 0 -> 49603 bytes .../static/src/interactions/payment_form.js | 374 +++++++++++++ .../src/interactions/terminal_payment.js | 136 +++++ fusion_poynt/utils.py | 251 +++++++++ .../views/payment_poynt_templates.xml | 84 +++ fusion_poynt/views/payment_provider_views.xml | 50 ++ fusion_poynt/views/poynt_terminal_views.xml | 110 ++++ 26 files changed, 2735 insertions(+), 9 deletions(-) create mode 100644 fusion_poynt/__init__.py create mode 100644 fusion_poynt/__manifest__.py create mode 100644 fusion_poynt/__pycache__/__init__.cpython-312.pyc create mode 100644 fusion_poynt/__pycache__/const.cpython-312.pyc create mode 100644 fusion_poynt/__pycache__/utils.cpython-312.pyc create mode 100644 fusion_poynt/const.py create mode 100644 fusion_poynt/controllers/__init__.py create mode 100644 fusion_poynt/controllers/main.py create mode 100644 fusion_poynt/data/payment_provider_data.xml create mode 100644 fusion_poynt/models/__init__.py create mode 100644 fusion_poynt/models/payment_provider.py create mode 100644 fusion_poynt/models/payment_token.py create mode 100644 fusion_poynt/models/payment_transaction.py create mode 100644 fusion_poynt/models/poynt_terminal.py create mode 100644 fusion_poynt/security/ir.model.access.csv create mode 100644 fusion_poynt/static/description/icon.png create mode 100644 fusion_poynt/static/src/interactions/payment_form.js create mode 100644 fusion_poynt/static/src/interactions/terminal_payment.js create mode 100644 fusion_poynt/utils.py create mode 100644 fusion_poynt/views/payment_poynt_templates.xml create mode 100644 fusion_poynt/views/payment_provider_views.xml create mode 100644 fusion_poynt/views/poynt_terminal_views.xml diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 3cc8f5b..e885548 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -169,7 +169,6 @@ class FusionTechnicianTask(models.Model): # ------------------------------------------------------------------ scheduled_date = fields.Date( string='Scheduled Date', - required=True, tracking=True, default=fields.Date.context_today, index=True, @@ -265,6 +264,7 @@ class FusionTechnicianTask(models.Model): # STATUS # ------------------------------------------------------------------ status = fields.Selection([ + ('pending', 'Pending'), ('scheduled', 'Scheduled'), ('en_route', 'En Route'), ('in_progress', 'In Progress'), @@ -851,6 +851,7 @@ class FusionTechnicianTask(models.Model): @api.depends('status') def _compute_color(self): color_map = { + 'pending': 5, # purple 'scheduled': 0, # grey 'en_route': 4, # blue 'in_progress': 2, # orange @@ -2126,7 +2127,7 @@ class FusionTechnicianTask(models.Model): 'time_start', 'time_start_display', 'time_end_display', 'status', 'scheduled_date', 'travel_time_minutes', 'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'], - order='scheduled_date asc, time_start asc', + order='scheduled_date asc NULLS LAST, time_start asc', limit=500, ) locations = self.env['fusion.technician.location'].get_latest_locations() diff --git a/fusion_claims/static/src/js/fusion_task_map_view.js b/fusion_claims/static/src/js/fusion_task_map_view.js index c131766..8ee5923 100644 --- a/fusion_claims/static/src/js/fusion_task_map_view.js +++ b/fusion_claims/static/src/js/fusion_task_map_view.js @@ -26,6 +26,7 @@ import { // ── Constants ─────────────────────────────────────────────────────── const STATUS_COLORS = { + pending: "#f59e0b", scheduled: "#3b82f6", en_route: "#f59e0b", in_progress: "#8b5cf6", @@ -34,6 +35,7 @@ const STATUS_COLORS = { rescheduled: "#f97316", }; const STATUS_LABELS = { + pending: "Pending", scheduled: "Scheduled", en_route: "En Route", in_progress: "In Progress", @@ -42,6 +44,7 @@ const STATUS_LABELS = { rescheduled: "Rescheduled", }; const STATUS_ICONS = { + pending: "fa-hourglass-half", scheduled: "fa-clock-o", en_route: "fa-truck", in_progress: "fa-wrench", @@ -51,12 +54,14 @@ const STATUS_ICONS = { }; // Date group keys +const GROUP_PENDING = "pending"; const GROUP_YESTERDAY = "yesterday"; const GROUP_TODAY = "today"; const GROUP_TOMORROW = "tomorrow"; const GROUP_THIS_WEEK = "this_week"; const GROUP_LATER = "later"; const GROUP_LABELS = { + [GROUP_PENDING]: "Pending", [GROUP_YESTERDAY]: "Yesterday", [GROUP_TODAY]: "Today", [GROUP_TOMORROW]: "Tomorrow", @@ -66,6 +71,7 @@ const GROUP_LABELS = { // Pin colours by day group const DAY_COLORS = { + [GROUP_PENDING]: "#f59e0b", // Amber [GROUP_YESTERDAY]: "#9ca3af", // Gray [GROUP_TODAY]: "#ef4444", // Red [GROUP_TOMORROW]: "#3b82f6", // Blue @@ -73,6 +79,7 @@ const DAY_COLORS = { [GROUP_LATER]: "#a855f7", // Purple }; const DAY_ICONS = { + [GROUP_PENDING]: "fa-hourglass-half", [GROUP_YESTERDAY]: "fa-history", [GROUP_TODAY]: "fa-exclamation-circle", [GROUP_TOMORROW]: "fa-arrow-right", @@ -137,9 +144,14 @@ function floatToTime12(flt) { return `${h12}:${String(m).padStart(2, "0")} ${ampm}`; } -/** Classify a "YYYY-MM-DD" string into one of our group keys */ +/** Classify a task into one of our group keys based on status and date */ +function classifyTask(task) { + if (task.status === "pending") return GROUP_PENDING; + return classifyDate(task.scheduled_date); +} + function classifyDate(dateStr) { - if (!dateStr) return GROUP_LATER; + if (!dateStr) return GROUP_PENDING; const now = new Date(); const todayStr = localDateStr(now); @@ -151,7 +163,6 @@ function classifyDate(dateStr) { tmr.setDate(tmr.getDate() + 1); const tomorrowStr = localDateStr(tmr); - // End of this week (Sunday) const endOfWeek = new Date(now); endOfWeek.setDate(endOfWeek.getDate() + (7 - endOfWeek.getDay())); const endOfWeekStr = localDateStr(endOfWeek); @@ -160,7 +171,7 @@ function classifyDate(dateStr) { if (dateStr === todayStr) return GROUP_TODAY; if (dateStr === tomorrowStr) return GROUP_TOMORROW; if (dateStr <= endOfWeekStr && dateStr > tomorrowStr) return GROUP_THIS_WEEK; - if (dateStr < yesterdayStr) return GROUP_YESTERDAY; // older lumped with yesterday + if (dateStr < yesterdayStr) return GROUP_YESTERDAY; return GROUP_LATER; } @@ -180,7 +191,7 @@ function groupTasks(tasksData, localInstanceId) { }); const groups = {}; - const order = [GROUP_YESTERDAY, GROUP_TODAY, GROUP_TOMORROW, GROUP_THIS_WEEK, GROUP_LATER]; + const order = [GROUP_PENDING, GROUP_YESTERDAY, GROUP_TODAY, GROUP_TOMORROW, GROUP_THIS_WEEK, GROUP_LATER]; for (const key of order) { groups[key] = { key, @@ -195,7 +206,7 @@ function groupTasks(tasksData, localInstanceId) { let globalIdx = 0; for (const task of sorted) { globalIdx++; - const g = classifyDate(task.scheduled_date); + const g = classifyTask(task); task._scheduleNum = globalIdx; task._group = g; task._dayColor = DAY_COLORS[g] || "#6b7280"; // Pin colour by day diff --git a/fusion_claims/static/src/xml/fusion_task_map_view.xml b/fusion_claims/static/src/xml/fusion_task_map_view.xml index 8c28297..a420046 100644 --- a/fusion_claims/static/src/xml/fusion_task_map_view.xml +++ b/fusion_claims/static/src/xml/fusion_task_map_view.xml @@ -165,6 +165,7 @@ Pins: + Pending Today Tomorrow This Week diff --git a/fusion_claims/views/technician_task_views.xml b/fusion_claims/views/technician_task_views.xml index 69aabc4..47036ad 100644 --- a/fusion_claims/views/technician_task_views.xml +++ b/fusion_claims/views/technician_task_views.xml @@ -49,6 +49,7 @@ domain="[('scheduled_date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')), ('scheduled_date', '<=', (context_today() + datetime.timedelta(days=6-context_today().weekday())).strftime('%Y-%m-%d'))]"/> + @@ -105,7 +106,7 @@ class="btn-secondary o_fc_calculate_travel" icon="fa-car" invisible="x_fc_is_shadow"/> + statusbar_visible="pending,scheduled,en_route,in_progress,completed"/> @@ -447,6 +448,15 @@ {'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1} + + + Pending Tasks + fusion.technician.task + list,kanban,form + + {'search_default_filter_pending': 1} + + @@ -478,6 +488,12 @@ action="action_technician_tasks" sequence="20"/> + + S5XWbeZBi|*prTL^q);%IQteH|N9Y6O5=uAFXp#-PX(8ajgYTdhp+}34 z;>8|BSk#LL=}jzNJUN>-#RqU-`_ zOpodIbxm<73KQ?VjpiECRqm`{Dk|}Lfn4{?IDrf%Mpu;*p2ewSC_Cw8ksqh3CjE4n zc!}VnfcauVlJ8o~U+&kJ#Sw8&>e5g2@HCsOq}fFb-mw_QKH1ku|AK{6%g| zX=B{EL*%yDz?@T7AoP-j-nQD}jPq8HF&@iSu{3Mp=P>ymgzh|nyuo60`2_#sF!WEw kE~fN4^p09Oke80*&v54FCWD literal 0 HcmV?d00001 diff --git a/fusion_poynt/__pycache__/const.cpython-312.pyc b/fusion_poynt/__pycache__/const.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..265416a450f5c803646b560da6c203e125c16454 GIT binary patch literal 1530 zcmZWpJ9Fby6qYSpcKm+D$!3#m*xecM0P(1LzEmNa@KM2$wPt87dh$s@^u zxC+05CNxm-8|Y|hQfh9+3>BS|?Wj1i*T#Ws>*}8GJnlK?JNECzVvd4!fBs|tw+u!7 z84L3T@^rnsf!7ZdrVI)vj08>^NlY6wP8lhjHqw|em~#qeeohz}oW(0Rhx53Ai??Yb zi%YnSE4YelxDN8lTM9RRN*Fo3iko;1uj40p13$fGj6B}NTX-8kyQPf+-hs~tw@IT2 zai2rn()Ekh%kjo}7!HFk+U>ycullwV><+zaH{5lda7=%;`}uh5zXqQ=qa_=r9eNjz zJN{^Hws3D-!{MNBTVdaGgSIsa&pp3CcE%shE&qSx)T$ly-@4u?99QRti;#P1Xc|db zm(F)2Wm`TbsjGfqku2^9ws+HR~i0cX>pv*oi07i-m+Sf(ioQV)QI|INxO>&FcARp|x4qp9^7Tfk-OOgQQfpiy-96A|%P!)-W9Tj^CKpe&zMy zmcSY~tt@G33M;BCrwNj1E`sxq8asNe_iHT4LVbn6doxi zC-vkmS_Y==gx>in$lGV)Eg5f93iG({I(}f=$y+x36appVsFAu9 zllL!!(;(Fus0yz|N!O-OLzYfO&6M>{SK!PebUH;hd9=^!UBKDX9s+$2!PN7>36E4# zQZ-i7RkO#+vLL=DkEUQYI;45Piac;-o}kWwBpsRP7~-g;7SmvMV95A|e;L^62_oAy zXMstT(-j4Y+^GqZBJ|j9d?ylBK@*NqoTT~-^)7Dn-&q^}3BujsHOYQ?>EY49+4H-g zNWc^P3fqKEBoY&bs(f-&h)VUFJe*fw-BHy)s!wi8Agr;8JE|Tvz5)!y^^fnU#zTtP zN#0SLlLl3P9u;0hrI%6hRaAOnm6}nZ24BFN;81(0t!%)%IoY6! z^{BQTt#3z_ji|m8`!9$8T2&R|;HuVss(o~VlqacTYyhdT)Ap>=QD+tpBDT>fRr5Hv>ww;`A7S=umr;-mbXyQ6w8O@xzD*B ze8nHcf~jSUWPZ+yPutW}Hqn%(kO+*ypAm;*f%`gK{jvGu;zz~5D{Y34t$U9V+_AL# z0|L-MKltyVvci=FQe#4mUALTVbQ<8Ei@N&Vl z(v>hFtk&4Ol1W|9NScu{L8Q8+8#5~Kf=H$=6%18EzF-GTzO4~GmsOE7 zlrcm#3na%DPPvL40e(8&=3HLwM#j}rSP^$q!e@yN$E~nK4Y**54nM4b{UMfjQZ`h>2Xt|<5((oQswPniQgU8P z6-b6OKwB;Rj43Da7d-CH~x56jC44?d`SGQgn*mz}Nq5rEuY7Avw*25>( zy(gIJxD^6FovKbRQ~=|6mtwfbK`&mQ1StLdq4$zCBEcQyofX<4~V<3xWi-} zx6Ux-oFWrN8l0FIm1a~KC6h{q>{0?-^$$wCoKB;7n)-XHz0o5*1#{+QX;PI56%yz; zAe_5M(k+eH;`kisas$>usd8}6S+}C4E5gU53tO0Y)yVmLMoUv%^EAT`^i?oUaf(fT9LfkUCFciNelF!hM(~o6bsyTjB9@3QRA_# z#xom@XUa!Uu3cP9t~bB*B;bwu7v6jl;>1|F?Zw5wddxDuy` zr9dg51kn>Po1H1I;|j@fIbx{0iT-H`UVdEL^`6y<6f_XhbfC8GyLc|}lF&^fHj=(2)qOu5#KPNSku z$%TxWnv~N3m`ZVW3_LiE!1_n$0DW@cKcwH2r{TTrqjSLGcDfnuX)>2Lb>MS;W)7N& zD&=$&b^=g6lTnqVG{ma7Gg%3@xm(I>d1nL=$L(@cDf$ZLROk7G#}f0hVcY>%#R@p9 zIx=ILd850l3-j)5loiszA48Lw zRxRJ;9QZw!p9va#Y0k8~rUo9GABGZic!`LUf&;T@m0Gz(*s69(HaaFzmmr{ESibxm zd=XtETSXAXn!PLR8f*zU{f-sL%X27GmXB(>#j6JTF{q&S#r&EE#@ZCJHpL`8g|B%7 z2BUQ`zJ>x!Oq1vf{Z>5sJMr4@#7o=NjlR&MaQ%}04~5@^JIhV&9}PWjYTs%~ZZswD z-PvsV?&8o_4QKCNcxe7hYV+z}{iY$c82-At_S4fViRHv6FKkvHUlbn4Tg&*@RgO2n z-)emM!lQV@r{+p=x%f$GGk*F>RUjT(41jg3ZCHscM^KWL2*0ZFK|SsE ztSZ6F-@;^w)}Un9VS&pnbSd{;U>i{=_FW3^*wA^8iFTP@CT<|Tz@|Y!Qv@L@0!_y% zICt>m1er%`pyzG_21~M(m2W|`mo5;30iGmh^#TI0&OA9l^sJVH0T3jbhIA$knuMM{ zg(y3U0i2eB5}n8#cbf>H`#$QF${yE_sA+%;Lp3W)U?eIFVgnArUe`SzIRjo1ARkRd#;RI!n6wdnUg_3UxtyE zj{tKNaMb+l<(BBY)Djgnt!3qmvDZV3LnsZnlA@TGVzbB)(8A!rxB$fh_pFAiso#pW zZA9C)qGvavXYZZ=$Jal9{bB27^y0$cBZ!j=OV?M)+B=_puu-4fjCO5?UtIUTc+gGr z0y%`79VGt$BXTbB0+B;;D*l%vOVkQi1jgZvEjfsiC{;`#Y&mdC5o`<-HJeKZBW;JRZ(`3N6njM^2Dt!3 z-76tia43%Kg5ujGq#SNseR-|vvo|)vXV<-FnOuO5S1y!tDi>Fv74j$?@j~u{yUi1i z273s5%A@Z)|1Ck{b3p14=u#k%bYRL2SS1K852K=ZcgH_xM8z$BPJbn2cus%dTi+ml z>xkg)2ypr#SCj}B|8rm#@-b-QX3gd19&O2P$pnV|Ok3^}PPS-XGp0&~;Wn{xV*CBy>r9u7= zoX!sF7SbUIkq!|Cmz7Z1^5O_^R$zz)ZceU{om9ycBxXf-iCu+|c*d&UZFm>LWh;hr z2lWi(2`)pXY#0F5@?+bOVnrCG%NjAv*AfA6k#L=skFL`S0;N=z5*4vJhvdRIH8n%& zSpoV0UQrbzl$v6c?DS8puV<`3)jQTR+@HGIGdemn{3caV(9Y_;3@5{&16GK>K810> zUNx;#*+!@6`|OP-GR*k^e#XzCfMicK7df(e{oWfJEf>ns+Qs1GaPw;0M!0pM|50tj zR&8RVHnCOPxl!9$79&3vKN6Q3R%4suvDGPXI%58Xx3)uE>{z+Bex+%-X|?lSY_qnb zT;II%&hk6!$GbM`Uo6)(tqd*?uD5h<)+Ni0M^|Q-XV;~34<Y?%+YPm;6DzR__T)ypz(5$_4XA1`Kp|+D2GdYF|TA6Ai$*278(mVCygk&F6|!# zLh9BYjOWFq5KJP0DMhJ}r=3x{8z}k1$Kh4hzEHeXbo{|sp zA8zj#=;5XK@A{R1A}YbV9wnrNHNNDXv{2^E+Cy~zZj^?^qWQS3) z9j_eRbfQkF=(a*e7!wo+WTn!-$5A-wud?@cKN z#1??P5+bf6rMssD5TdJwYa*LKTOGi5r6njAJDKZgLYT}(SJ;SAUbpSfQ~>@P^cZN; z!3vCw^$&~<^jqG6p1wiwkI|j6#PZ&Rm z1)@S=FG5T|fn2q!6b;S`AhKYO;*_e|H6UQuAR|C)1RW(yxG~Uc)r|~a85$l)^<0}6 zOkw(T2$ltT20ycrR)b5yF>OH#^jL(vj}-hPqXWY*+(%S=3^4E80E>Zn+gTG6{0KM@ zkozbnKlf1AoOy44D_9X9av2EFWl>UO0UzIn!oUa(+36^5oMmJw!&5fH$q!)=+6MzI z1$c7}TwVLx?R&GE@$(B;z6#eou4^ebHJ6*eQ;ye_8%~s4PC?)n%ufX)jguM)WHoe#i~U9coD z{h#I`(;A{D3O+br@WE4#)fVVbV%bpSD@_c8J)N`1{Kvz@5>h+1PoW~`TU51Y44vF;O=TX z6Cvje&y9{IC!rz&!J#hbKKHfD_H<=aKDY?CS=|pp?p^rbp@^ao zoXgd^ru5MCf)%E0u^)n_mba_bY1x>;7?V9ST}6p5)}qZ#PQjt5o?|AJ%t1RG4`R~a z3Nj?=$>+&mW3v}xdxjzN@JLW&sk&QXdWMst&_z4Fz!hXPs8U&5z4#bUsm*Ph3=#!QxJrp|0i8YU7N6K}rfGH<2 zW7Zm83@k-9#1=d-YABz22_B;xqV%|OUX&1qhc74i;e=8sl(9<9ALH?zuO+o&Jr%jE#|LM69AKy8W;QiY-dG2j~gnuTS<(ZrHKfxf6 ATL1t6 literal 0 HcmV?d00001 diff --git a/fusion_poynt/const.py b/fusion_poynt/const.py new file mode 100644 index 0000000..6d9328d --- /dev/null +++ b/fusion_poynt/const.py @@ -0,0 +1,84 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +API_BASE_URL = 'https://services.poynt.net' +API_VERSION = '1.2' + +# Poynt test/sandbox environment +API_BASE_URL_TEST = 'https://services-eu.poynt.net' + +TOKEN_ENDPOINT = '/token' + +# Poynt OAuth authorization URL for merchant onboarding +OAUTH_AUTHORIZE_URL = 'https://poynt.net/applications/authorize' +OAUTH_SIGNOUT_URL = 'https://services.poynt.net/auth/signout' + +# Poynt public key URL for JWT verification +POYNT_PUBLIC_KEY_URL = 'https://poynt.net' + +DEFAULT_PAYMENT_METHOD_CODES = { + 'card', + 'visa', + 'mastercard', + 'amex', + 'discover', +} + +# Mapping of Poynt transaction statuses to Odoo payment transaction states. +STATUS_MAPPING = { + 'authorized': ('AUTHORIZED',), + 'done': ('CAPTURED', 'SETTLED'), + 'cancel': ('VOIDED', 'CANCELED'), + 'error': ('DECLINED', 'FAILED', 'REFUND_FAILED'), + 'refund': ('REFUNDED',), +} + +# Poynt transaction actions +TRANSACTION_ACTION = { + 'authorize': 'AUTHORIZE', + 'capture': 'CAPTURE', + 'refund': 'REFUND', + 'void': 'VOID', + 'sale': 'SALE', +} + +# Webhook event types we handle +HANDLED_WEBHOOK_EVENTS = [ + 'TRANSACTION_AUTHORIZED', + 'TRANSACTION_CAPTURED', + 'TRANSACTION_VOIDED', + 'TRANSACTION_REFUNDED', + 'TRANSACTION_DECLINED', + 'TRANSACTION_UPDATED', + 'ORDER_COMPLETED', + 'ORDER_CANCELLED', +] + +# Card brand mapping from Poynt scheme to Odoo payment method codes +CARD_BRAND_MAPPING = { + 'VISA': 'visa', + 'MASTERCARD': 'mastercard', + 'AMERICAN_EXPRESS': 'amex', + 'DISCOVER': 'discover', + 'DINERS_CLUB': 'diners_club', + 'JCB': 'jcb', +} + +# Terminal statuses +TERMINAL_STATUS = { + 'online': 'ONLINE', + 'offline': 'OFFLINE', + 'unknown': 'UNKNOWN', +} + +# Poynt amounts are in cents (minor currency units) +CURRENCY_DECIMALS = { + 'JPY': 0, + 'KRW': 0, +} + +# Sensitive keys that should be masked in logs +SENSITIVE_KEYS = { + 'poynt_private_key', + 'accessToken', + 'refreshToken', +} diff --git a/fusion_poynt/controllers/__init__.py b/fusion_poynt/controllers/__init__.py new file mode 100644 index 0000000..80ee4da --- /dev/null +++ b/fusion_poynt/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/fusion_poynt/controllers/main.py b/fusion_poynt/controllers/main.py new file mode 100644 index 0000000..7a05de1 --- /dev/null +++ b/fusion_poynt/controllers/main.py @@ -0,0 +1,518 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hashlib +import hmac +import json +import logging +import pprint + +from werkzeug.exceptions import Forbidden + +from odoo import http +from odoo.exceptions import ValidationError +from odoo.http import request +from odoo.tools import mute_logger + +from odoo.addons.fusion_poynt import const +from odoo.addons.fusion_poynt import utils as poynt_utils + +_logger = logging.getLogger(__name__) + + +class PoyntController(http.Controller): + _return_url = '/payment/poynt/return' + _webhook_url = '/payment/poynt/webhook' + _terminal_callback_url = '/payment/poynt/terminal/callback' + _oauth_callback_url = '/payment/poynt/oauth/callback' + + # === RETURN ROUTE === # + + @http.route(_return_url, type='http', methods=['GET'], auth='public') + def poynt_return(self, **data): + """Process the return from a Poynt payment flow. + + The customer is redirected here after completing (or abandoning) a payment. + We look up the transaction by reference and fetch the latest status from Poynt. + """ + tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference( + 'poynt', data, + ) + + if tx_sudo and tx_sudo.poynt_transaction_id: + try: + txn_data = tx_sudo.provider_id._poynt_make_request( + 'GET', + f'transactions/{tx_sudo.poynt_transaction_id}', + ) + payment_data = { + 'reference': tx_sudo.reference, + 'poynt_transaction_id': txn_data.get('id'), + 'poynt_order_id': tx_sudo.poynt_order_id, + 'poynt_status': txn_data.get('status', ''), + 'funding_source': txn_data.get('fundingSource', {}), + } + tx_sudo._process('poynt', payment_data) + except ValidationError: + _logger.error( + "Failed to fetch Poynt transaction %s on return.", + tx_sudo.poynt_transaction_id, + ) + + with mute_logger('werkzeug'): + return request.redirect('/payment/status') + + # === WEBHOOK ROUTE === # + + @http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False) + def poynt_webhook(self): + """Process webhook notifications from Poynt. + + Poynt sends cloud hook events for transaction and order status changes. + We verify the payload, match it to an Odoo transaction, and update accordingly. + + :return: An empty JSON response to acknowledge the notification. + :rtype: Response + """ + try: + raw_body = request.httprequest.data.decode('utf-8') + event = json.loads(raw_body) + except (ValueError, UnicodeDecodeError): + _logger.warning("Received invalid JSON from Poynt webhook") + return request.make_json_response({'status': 'error'}, status=400) + + _logger.info( + "Poynt webhook notification received:\n%s", + pprint.pformat(event), + ) + + try: + event_type = event.get('eventType', event.get('type', '')) + resource = event.get('resource', {}) + business_id = event.get('businessId', '') + + if event_type not in const.HANDLED_WEBHOOK_EVENTS: + _logger.info("Ignoring unhandled Poynt event type: %s", event_type) + return request.make_json_response({'status': 'ignored'}) + + self._verify_webhook_signature(event, business_id) + + if event_type.startswith('TRANSACTION_'): + self._handle_transaction_webhook(event_type, resource, business_id) + elif event_type.startswith('ORDER_'): + self._handle_order_webhook(event_type, resource, business_id) + + except ValidationError: + _logger.exception("Unable to process Poynt webhook; acknowledging to avoid retries") + except Forbidden: + _logger.warning("Poynt webhook signature verification failed") + return request.make_json_response({'status': 'forbidden'}, status=403) + + return request.make_json_response({'status': 'ok'}) + + def _handle_transaction_webhook(self, event_type, resource, business_id): + """Process a transaction-related webhook event. + + :param str event_type: The Poynt event type. + :param dict resource: The Poynt resource data from the webhook. + :param str business_id: The Poynt business ID. + """ + transaction_id = resource.get('id', '') + if not transaction_id: + _logger.warning("Transaction webhook missing transaction ID") + return + + provider_sudo = request.env['payment.provider'].sudo().search([ + ('code', '=', 'poynt'), + ('poynt_business_id', '=', business_id), + ], limit=1) + + if not provider_sudo: + _logger.warning("No Poynt provider found for business %s", business_id) + return + + try: + txn_data = provider_sudo._poynt_make_request( + 'GET', f'transactions/{transaction_id}', + ) + except ValidationError: + _logger.error("Failed to fetch transaction %s from Poynt", transaction_id) + return + + reference = txn_data.get('notes', '') + status = txn_data.get('status', '') + + payment_data = { + 'reference': reference, + 'poynt_transaction_id': transaction_id, + 'poynt_status': status, + 'funding_source': txn_data.get('fundingSource', {}), + } + + tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference( + 'poynt', payment_data, + ) + + if not tx_sudo: + _logger.warning( + "No matching transaction for Poynt txn %s (ref: %s)", + transaction_id, reference, + ) + return + + if event_type == 'TRANSACTION_REFUNDED': + action = txn_data.get('action', '') + if action == 'REFUND': + parent_id = txn_data.get('parentId', '') + source_tx = request.env['payment.transaction'].sudo().search([ + ('provider_reference', '=', parent_id), + ('provider_code', '=', 'poynt'), + ], limit=1) + if source_tx: + refund_amount = poynt_utils.parse_poynt_amount( + txn_data.get('amounts', {}).get('transactionAmount', 0), + source_tx.currency_id, + ) + existing_refund = source_tx.child_transaction_ids.filtered( + lambda t: t.provider_reference == transaction_id + ) + if not existing_refund: + refund_tx = source_tx._create_child_transaction( + refund_amount, is_refund=True, + ) + payment_data['reference'] = refund_tx.reference + refund_tx._process('poynt', payment_data) + return + + tx_sudo._process('poynt', payment_data) + + def _handle_order_webhook(self, event_type, resource, business_id): + """Process an order-related webhook event. + + :param str event_type: The Poynt event type. + :param dict resource: The Poynt resource data from the webhook. + :param str business_id: The Poynt business ID. + """ + order_id = resource.get('id', '') + if not order_id: + return + + tx_sudo = request.env['payment.transaction'].sudo().search([ + ('poynt_order_id', '=', order_id), + ('provider_code', '=', 'poynt'), + ], limit=1) + + if not tx_sudo: + _logger.info("No Odoo transaction found for Poynt order %s", order_id) + return + + if event_type == 'ORDER_CANCELLED' and tx_sudo.state not in ('done', 'cancel', 'error'): + tx_sudo._set_canceled() + + def _verify_webhook_signature(self, event, business_id): + """Verify the webhook notification signature. + + :param dict event: The webhook event data. + :param str business_id: The Poynt business ID. + :raises Forbidden: If signature verification fails. + """ + provider_sudo = request.env['payment.provider'].sudo().search([ + ('code', '=', 'poynt'), + ('poynt_business_id', '=', business_id), + ], limit=1) + + if not provider_sudo or not provider_sudo.poynt_webhook_secret: + _logger.info("No webhook secret configured; skipping signature verification") + return + + signature = request.httprequest.headers.get('X-Poynt-Webhook-Signature', '') + if not signature: + _logger.warning("Webhook missing X-Poynt-Webhook-Signature header") + return + + raw_body = request.httprequest.data + expected_signature = hmac.new( + provider_sudo.poynt_webhook_secret.encode('utf-8'), + raw_body, + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(signature, expected_signature): + _logger.warning("Poynt webhook signature mismatch") + raise Forbidden() + + # === TERMINAL CALLBACK ROUTE === # + + @http.route( + _terminal_callback_url, type='http', methods=['POST'], + auth='public', csrf=False, + ) + def poynt_terminal_callback(self, **data): + """Handle callback from a Poynt terminal after a payment completes. + + The terminal sends transaction results here after the customer + taps/inserts their card at the physical device. + + :return: A JSON acknowledgement. + :rtype: Response + """ + try: + raw_body = request.httprequest.data.decode('utf-8') + event = json.loads(raw_body) + except (ValueError, UnicodeDecodeError): + return request.make_json_response({'status': 'error'}, status=400) + + _logger.info( + "Poynt terminal callback received:\n%s", + pprint.pformat(event), + ) + + reference = event.get('referenceId', event.get('data', {}).get('referenceId', '')) + transaction_id = event.get('transactionId', event.get('data', {}).get('transactionId', '')) + + if not reference and not transaction_id: + _logger.warning("Terminal callback missing reference and transaction ID") + return request.make_json_response({'status': 'error'}, status=400) + + payment_data = { + 'reference': reference, + 'poynt_transaction_id': transaction_id, + } + + tx_sudo = request.env['payment.transaction'].sudo()._search_by_reference( + 'poynt', payment_data, + ) + + if tx_sudo and transaction_id: + try: + txn_data = tx_sudo.provider_id._poynt_make_request( + 'GET', f'transactions/{transaction_id}', + ) + payment_data.update({ + 'poynt_status': txn_data.get('status', ''), + 'funding_source': txn_data.get('fundingSource', {}), + 'poynt_order_id': tx_sudo.poynt_order_id, + }) + tx_sudo._process('poynt', payment_data) + except ValidationError: + _logger.error("Failed to process terminal callback for txn %s", transaction_id) + + return request.make_json_response({'status': 'ok'}) + + # === OAUTH CALLBACK ROUTE === # + + @http.route(_oauth_callback_url, type='http', methods=['GET'], auth='user') + def poynt_oauth_callback(self, **data): + """Handle the OAuth2 authorization callback from Poynt. + + After a merchant authorizes the application on poynt.net, they are + redirected here with an authorization code (JWT) and business ID. + + :return: Redirect to the payment provider form. + :rtype: Response + """ + code = data.get('code', '') + status = data.get('status', '') + context = data.get('context', '') + business_id = data.get('businessId', '') + + if status != 'AUTHORIZED': + _logger.warning("Poynt OAuth callback with status: %s", status) + return request.redirect('/odoo/settings') + + if code: + try: + import jwt as pyjwt + decoded = pyjwt.decode(code, options={"verify_signature": False}) + business_id = decoded.get('poynt.biz', business_id) + except Exception: + _logger.warning("Failed to decode Poynt OAuth JWT") + + if business_id and context: + try: + provider_id = int(context) + provider = request.env['payment.provider'].browse(provider_id) + if provider.exists() and provider.code == 'poynt': + provider.sudo().write({ + 'poynt_business_id': business_id, + }) + _logger.info( + "Poynt OAuth: linked business %s to provider %s", + business_id, provider_id, + ) + except (ValueError, TypeError): + _logger.warning("Invalid provider context in Poynt OAuth callback: %s", context) + + return request.redirect('/odoo/settings') + + # === JSON-RPC ROUTES (called from frontend JS) === # + + @http.route('/payment/poynt/terminals', type='jsonrpc', auth='public') + def poynt_get_terminals(self, provider_id=None, **kwargs): + """Return available Poynt terminals for the given provider. + + :param int provider_id: The payment provider ID. + :return: List of terminal dicts with id, name, status. + :rtype: list + """ + if not provider_id: + return [] + + terminals = request.env['poynt.terminal'].sudo().search([ + ('provider_id', '=', int(provider_id)), + ('active', '=', True), + ]) + + return [{ + 'id': t.id, + 'name': t.name, + 'status': t.status, + 'device_id': t.device_id, + } for t in terminals] + + @http.route('/payment/poynt/process_card', type='jsonrpc', auth='public') + def poynt_process_card(self, reference=None, poynt_order_id=None, + card_number=None, exp_month=None, exp_year=None, + cvv=None, cardholder_name=None, **kwargs): + """Process a card payment through Poynt Cloud API. + + The frontend sends card details which are passed to Poynt for + authorization. Card data is NOT stored in Odoo. + + :return: Dict with success status or error message. + :rtype: dict + """ + if not reference: + return {'error': 'Missing payment reference.'} + + tx_sudo = request.env['payment.transaction'].sudo().search([ + ('reference', '=', reference), + ('provider_code', '=', 'poynt'), + ], limit=1) + + if not tx_sudo: + return {'error': 'Transaction not found.'} + + try: + funding_source = { + 'type': 'CREDIT_DEBIT', + 'card': { + 'number': card_number, + 'expirationMonth': int(exp_month), + 'expirationYear': int(exp_year), + 'cardHolderFullName': cardholder_name or '', + }, + 'verificationData': { + 'cvData': cvv, + }, + 'entryDetails': { + 'customerPresenceStatus': 'ECOMMERCE', + 'entryMode': 'KEYED', + }, + } + + action = 'AUTHORIZE' if tx_sudo.provider_id.capture_manually else 'SALE' + minor_amount = poynt_utils.format_poynt_amount( + tx_sudo.amount, tx_sudo.currency_id, + ) + + txn_payload = { + 'action': action, + 'amounts': { + 'transactionAmount': minor_amount, + 'orderAmount': minor_amount, + 'tipAmount': 0, + 'cashbackAmount': 0, + 'currency': tx_sudo.currency_id.name, + }, + 'fundingSource': funding_source, + 'context': { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', + }, + 'notes': reference, + } + + if poynt_order_id: + txn_payload['references'] = [{ + 'id': poynt_order_id, + 'type': 'POYNT_ORDER', + }] + + result = tx_sudo.provider_id._poynt_make_request( + 'POST', 'transactions', payload=txn_payload, + ) + + transaction_id = result.get('id', '') + status = result.get('status', '') + + tx_sudo.write({ + 'poynt_transaction_id': transaction_id, + 'provider_reference': transaction_id, + }) + + payment_data = { + 'reference': reference, + 'poynt_transaction_id': transaction_id, + 'poynt_order_id': poynt_order_id, + 'poynt_status': status, + 'funding_source': result.get('fundingSource', {}), + } + tx_sudo._process('poynt', payment_data) + + return {'success': True, 'status': status} + except ValidationError as e: + return {'error': str(e)} + except Exception as e: + _logger.error("Card payment processing failed: %s", e) + return {'error': 'Payment processing failed. Please try again.'} + + @http.route('/payment/poynt/send_to_terminal', type='jsonrpc', auth='public') + def poynt_send_to_terminal(self, reference=None, terminal_id=None, + poynt_order_id=None, **kwargs): + """Send a payment request to a Poynt terminal device. + + :return: Dict with success status or error message. + :rtype: dict + """ + if not reference or not terminal_id: + return {'error': 'Missing reference or terminal ID.'} + + tx_sudo = request.env['payment.transaction'].sudo().search([ + ('reference', '=', reference), + ('provider_code', '=', 'poynt'), + ], limit=1) + + if not tx_sudo: + return {'error': 'Transaction not found.'} + + terminal = request.env['poynt.terminal'].sudo().browse(int(terminal_id)) + if not terminal.exists(): + return {'error': 'Terminal not found.'} + + try: + result = terminal.action_send_payment_to_terminal( + amount=tx_sudo.amount, + currency=tx_sudo.currency_id, + reference=reference, + order_id=poynt_order_id, + ) + return {'success': True, 'message_id': result.get('id', '')} + except (ValidationError, Exception) as e: + return {'error': str(e)} + + @http.route('/payment/poynt/terminal_status', type='jsonrpc', auth='public') + def poynt_terminal_status(self, reference=None, terminal_id=None, **kwargs): + """Poll the status of a terminal payment. + + :return: Dict with current payment status. + :rtype: dict + """ + if not reference: + return {'status': 'error', 'message': 'Missing reference.'} + + terminal = request.env['poynt.terminal'].sudo().browse(int(terminal_id or 0)) + if not terminal.exists(): + return {'status': 'error', 'message': 'Terminal not found.'} + + return terminal.action_check_terminal_payment_status(reference) diff --git a/fusion_poynt/data/payment_provider_data.xml b/fusion_poynt/data/payment_provider_data.xml new file mode 100644 index 0000000..40290ea --- /dev/null +++ b/fusion_poynt/data/payment_provider_data.xml @@ -0,0 +1,12 @@ + + + + + Poynt + poynt + + True + disabled + + + diff --git a/fusion_poynt/models/__init__.py b/fusion_poynt/models/__init__.py new file mode 100644 index 0000000..105dc07 --- /dev/null +++ b/fusion_poynt/models/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import payment_provider +from . import payment_token +from . import payment_transaction +from . import poynt_terminal diff --git a/fusion_poynt/models/payment_provider.py b/fusion_poynt/models/payment_provider.py new file mode 100644 index 0000000..1f9b88f --- /dev/null +++ b/fusion_poynt/models/payment_provider.py @@ -0,0 +1,377 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import logging +import time + +import requests + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.fusion_poynt import const +from odoo.addons.fusion_poynt import utils as poynt_utils + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + code = fields.Selection( + selection_add=[('poynt', "Poynt")], + ondelete={'poynt': 'set default'}, + ) + poynt_application_id = fields.Char( + string="Application ID", + help="The Poynt application ID (urn:aid:...) from your developer portal.", + required_if_provider='poynt', + copy=False, + ) + poynt_private_key = fields.Text( + string="Private Key (PEM)", + help="The RSA private key in PEM format, downloaded from the Poynt developer portal. " + "Used to sign JWT tokens for OAuth2 authentication.", + required_if_provider='poynt', + copy=False, + groups='base.group_system', + ) + poynt_business_id = fields.Char( + string="Business ID", + help="The merchant's Poynt business UUID.", + required_if_provider='poynt', + copy=False, + ) + poynt_store_id = fields.Char( + string="Store ID", + help="The Poynt store UUID for this location.", + copy=False, + ) + poynt_webhook_secret = fields.Char( + string="Webhook Secret", + help="Secret key used to verify webhook notifications from Poynt.", + copy=False, + groups='base.group_system', + ) + + # Cached access token fields (not visible in UI) + _poynt_access_token = fields.Char( + string="Access Token", + copy=False, + groups='base.group_system', + ) + _poynt_token_expiry = fields.Integer( + string="Token Expiry Timestamp", + copy=False, + groups='base.group_system', + ) + + # === COMPUTE METHODS === # + + def _compute_feature_support_fields(self): + """Override of `payment` to enable additional features.""" + super()._compute_feature_support_fields() + self.filtered(lambda p: p.code == 'poynt').update({ + 'support_manual_capture': 'full_only', + 'support_refund': 'partial', + 'support_tokenization': True, + }) + + # === CRUD METHODS === # + + def _get_default_payment_method_codes(self): + """Override of `payment` to return the default payment method codes.""" + self.ensure_one() + if self.code != 'poynt': + return super()._get_default_payment_method_codes() + return const.DEFAULT_PAYMENT_METHOD_CODES + + # === BUSINESS METHODS - AUTHENTICATION === # + + def _poynt_get_access_token(self): + """Obtain an OAuth2 access token from Poynt using JWT bearer grant. + + Caches the token and only refreshes when it's about to expire. + + :return: The access token string. + :rtype: str + :raises ValidationError: If authentication fails. + """ + self.ensure_one() + + now = int(time.time()) + if self._poynt_access_token and self._poynt_token_expiry and now < self._poynt_token_expiry - 30: + return self._poynt_access_token + + jwt_assertion = poynt_utils.create_self_signed_jwt( + self.poynt_application_id, + self.poynt_private_key, + ) + + token_url = f"{const.API_BASE_URL}{const.TOKEN_ENDPOINT}" + if self.state == 'test': + token_url = f"{const.API_BASE_URL_TEST}{const.TOKEN_ENDPOINT}" + + try: + response = requests.post( + token_url, + data={ + 'grantType': 'urn:ietf:params:oauth:grant-type:jwt-bearer', + 'assertion': jwt_assertion, + }, + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + 'Api-Version': const.API_VERSION, + }, + timeout=30, + ) + response.raise_for_status() + token_data = response.json() + except requests.exceptions.RequestException as e: + _logger.error("Poynt OAuth2 token request failed: %s", e) + raise ValidationError( + _("Failed to authenticate with Poynt. Please check your credentials. Error: %s", e) + ) + + access_token = token_data.get('accessToken') + expires_in = token_data.get('expiresIn', 900) + + if not access_token: + raise ValidationError( + _("Poynt authentication returned no access token. " + "Please verify your Application ID and Private Key.") + ) + + self.sudo().write({ + '_poynt_access_token': access_token, + '_poynt_token_expiry': now + expires_in, + }) + + return access_token + + # === BUSINESS METHODS - API REQUESTS === # + + def _poynt_make_request(self, method, endpoint, payload=None, params=None, + business_scoped=True, store_scoped=False): + """Make an authenticated API request to the Poynt REST API. + + :param str method: HTTP method (GET, POST, PUT, PATCH, DELETE). + :param str endpoint: The API endpoint path. + :param dict payload: The JSON request body (optional). + :param dict params: The query parameters (optional). + :param bool business_scoped: Whether to scope the URL to the business. + :param bool store_scoped: Whether to scope the URL to the store. + :return: The parsed JSON response. + :rtype: dict + :raises ValidationError: If the API request fails. + """ + self.ensure_one() + + access_token = self._poynt_get_access_token() + is_test = self.state == 'test' + + business_id = self.poynt_business_id if business_scoped else None + store_id = self.poynt_store_id if store_scoped and self.poynt_store_id else None + + url = poynt_utils.build_api_url( + endpoint, + business_id=business_id, + store_id=store_id, + is_test=is_test, + ) + + request_id = poynt_utils.generate_request_id() + headers = poynt_utils.build_api_headers(access_token, request_id=request_id) + + _logger.info( + "Poynt API %s request to %s (request_id=%s)", + method, url, request_id, + ) + + try: + response = requests.request( + method, + url, + json=payload, + params=params, + headers=headers, + timeout=60, + ) + except requests.exceptions.RequestException as e: + _logger.error("Poynt API request failed: %s", e) + raise ValidationError(_("Communication with Poynt failed: %s", e)) + + if response.status_code == 401: + self.sudo().write({ + '_poynt_access_token': False, + '_poynt_token_expiry': 0, + }) + raise ValidationError( + _("Poynt authentication expired. Please retry.") + ) + + if response.status_code == 204: + return {} + + try: + result = response.json() + except ValueError: + _logger.error("Poynt returned non-JSON response: %s", response.text[:500]) + raise ValidationError(_("Poynt returned an invalid response.")) + + if response.status_code >= 400: + error_msg = result.get('message', result.get('developerMessage', 'Unknown error')) + _logger.error( + "Poynt API error %s: %s (request_id=%s)", + response.status_code, error_msg, request_id, + ) + raise ValidationError( + _("Poynt API error (%(code)s): %(msg)s", + code=response.status_code, msg=error_msg) + ) + + return result + + # === BUSINESS METHODS - INLINE FORM === # + + def _poynt_get_inline_form_values(self, amount, currency, partner_id, is_validation, + payment_method_sudo=None, **kwargs): + """Return a serialized JSON of values needed to render the inline payment form. + + :param float amount: The payment amount. + :param recordset currency: The currency of the transaction. + :param int partner_id: The partner ID. + :param bool is_validation: Whether this is a validation (tokenization) operation. + :param recordset payment_method_sudo: The sudoed payment method record. + :return: The JSON-serialized inline form values. + :rtype: str + """ + self.ensure_one() + + partner = self.env['res.partner'].browse(partner_id).exists() + minor_amount = poynt_utils.format_poynt_amount(amount, currency) if amount else 0 + + inline_form_values = { + 'business_id': self.poynt_business_id, + 'application_id': self.poynt_application_id, + 'currency_name': currency.name if currency else 'USD', + 'minor_amount': minor_amount, + 'capture_method': 'manual' if self.capture_manually else 'automatic', + 'is_test': self.state == 'test', + 'billing_details': { + 'name': partner.name or '', + 'email': partner.email or '', + 'phone': partner.phone or '', + 'address': { + 'line1': partner.street or '', + 'line2': partner.street2 or '', + 'city': partner.city or '', + 'state': partner.state_id.code or '', + 'country': partner.country_id.code or '', + 'postal_code': partner.zip or '', + }, + }, + 'is_tokenization_required': ( + self.allow_tokenization + and self._is_tokenization_required(**kwargs) + and payment_method_sudo + and payment_method_sudo.support_tokenization + ), + } + return json.dumps(inline_form_values) + + # === ACTION METHODS === # + + def action_poynt_test_connection(self): + """Test the connection to Poynt by fetching business info. + + :return: A notification action with the result. + :rtype: dict + """ + self.ensure_one() + + try: + result = self._poynt_make_request('GET', '') + business_name = result.get('legalName', result.get('doingBusinessAs', 'Unknown')) + message = _( + "Connection successful. Business: %(name)s", + name=business_name, + ) + notification_type = 'success' + except (ValidationError, UserError) as e: + message = _("Connection failed: %(error)s", error=str(e)) + notification_type = 'danger' + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'sticky': False, + 'type': notification_type, + }, + } + + def action_poynt_fetch_terminals(self): + """Fetch terminal devices from Poynt and create/update local records. + + :return: A notification action with the result. + :rtype: dict + """ + self.ensure_one() + + try: + store_id = self.poynt_store_id + if store_id: + endpoint = f'stores/{store_id}/storeDevices' + else: + endpoint = 'storeDevices' + + result = self._poynt_make_request('GET', endpoint) + devices = result if isinstance(result, list) else result.get('storeDevices', []) + + terminal_model = self.env['poynt.terminal'] + created = 0 + updated = 0 + + for device in devices: + device_id = device.get('deviceId', '') + existing = terminal_model.search([ + ('device_id', '=', device_id), + ('provider_id', '=', self.id), + ], limit=1) + + vals = { + 'name': device.get('name', device_id), + 'device_id': device_id, + 'serial_number': device.get('serialNumber', ''), + 'provider_id': self.id, + 'status': 'online' if device.get('status') == 'ACTIVATED' else 'offline', + 'store_id_poynt': device.get('storeId', ''), + } + + if existing: + existing.write(vals) + updated += 1 + else: + terminal_model.create(vals) + created += 1 + + message = _( + "Terminals synced: %(created)s created, %(updated)s updated.", + created=created, updated=updated, + ) + notification_type = 'success' + except (ValidationError, UserError) as e: + message = _("Failed to fetch terminals: %(error)s", error=str(e)) + notification_type = 'danger' + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'message': message, + 'sticky': False, + 'type': notification_type, + }, + } diff --git a/fusion_poynt/models/payment_token.py b/fusion_poynt/models/payment_token.py new file mode 100644 index 0000000..f863769 --- /dev/null +++ b/fusion_poynt/models/payment_token.py @@ -0,0 +1,55 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + poynt_card_id = fields.Char( + string="Poynt Card ID", + help="The unique card identifier stored on the Poynt platform.", + readonly=True, + ) + + def _poynt_validate_stored_card(self): + """Validate that the stored card is still usable on Poynt. + + Fetches the card details from Poynt to confirm the card ID is valid + and the card is still active. + + :return: True if the card is valid. + :rtype: bool + :raises ValidationError: If the card cannot be validated. + """ + self.ensure_one() + + if not self.poynt_card_id: + raise ValidationError( + _("No Poynt card ID found on this payment token.") + ) + + try: + result = self.provider_id._poynt_make_request( + 'GET', + f'cards/{self.poynt_card_id}', + ) + status = result.get('status', '') + if status != 'ACTIVE': + raise ValidationError( + _("The stored card is no longer active on Poynt (status: %(status)s).", + status=status) + ) + return True + except ValidationError: + raise + except Exception as e: + _logger.warning("Failed to validate Poynt card %s: %s", self.poynt_card_id, e) + raise ValidationError( + _("Unable to validate the stored card with Poynt.") + ) diff --git a/fusion_poynt/models/payment_transaction.py b/fusion_poynt/models/payment_transaction.py new file mode 100644 index 0000000..4f18e67 --- /dev/null +++ b/fusion_poynt/models/payment_transaction.py @@ -0,0 +1,386 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from werkzeug.urls import url_encode + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.urls import urljoin as url_join + +from odoo.addons.fusion_poynt import const +from odoo.addons.fusion_poynt import utils as poynt_utils +from odoo.addons.fusion_poynt.controllers.main import PoyntController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + poynt_order_id = fields.Char( + string="Poynt Order ID", + readonly=True, + copy=False, + ) + poynt_transaction_id = fields.Char( + string="Poynt Transaction ID", + readonly=True, + copy=False, + ) + + # === BUSINESS METHODS - PAYMENT FLOW === # + + def _get_specific_processing_values(self, processing_values): + """Override of payment to return Poynt-specific processing values. + + For direct (online) payments we create a Poynt order upfront and return + identifiers plus the return URL so the frontend JS can complete the flow. + """ + if self.provider_code != 'poynt': + return super()._get_specific_processing_values(processing_values) + + if self.operation == 'online_token': + return {} + + poynt_data = self._poynt_create_order_and_authorize() + + base_url = self.provider_id.get_base_url() + return_url = url_join( + base_url, + f'{PoyntController._return_url}?{url_encode({"reference": self.reference})}', + ) + + return { + 'poynt_order_id': poynt_data.get('order_id', ''), + 'poynt_transaction_id': poynt_data.get('transaction_id', ''), + 'return_url': return_url, + 'business_id': self.provider_id.poynt_business_id, + 'is_test': self.provider_id.state == 'test', + } + + def _send_payment_request(self): + """Override of `payment` to send a payment request to Poynt.""" + if self.provider_code != 'poynt': + return super()._send_payment_request() + + if self.operation in ('online_token', 'offline'): + return self._poynt_process_token_payment() + + poynt_data = self._poynt_create_order_and_authorize() + if poynt_data: + payment_data = { + 'reference': self.reference, + 'poynt_order_id': poynt_data.get('order_id'), + 'poynt_transaction_id': poynt_data.get('transaction_id'), + 'poynt_status': poynt_data.get('status', 'AUTHORIZED'), + 'funding_source': poynt_data.get('funding_source', {}), + } + self._process('poynt', payment_data) + + def _poynt_create_order_and_authorize(self): + """Create a Poynt order and authorize the transaction. + + :return: Dict with order_id, transaction_id, status, and funding_source. + :rtype: dict + """ + try: + order_payload = poynt_utils.build_order_payload( + self.reference, self.amount, self.currency_id, + ) + order_result = self.provider_id._poynt_make_request( + 'POST', 'orders', payload=order_payload, + ) + order_id = order_result.get('id', '') + self.poynt_order_id = order_id + + action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE' + txn_payload = poynt_utils.build_transaction_payload( + action=action, + amount=self.amount, + currency=self.currency_id, + order_id=order_id, + reference=self.reference, + ) + txn_result = self.provider_id._poynt_make_request( + 'POST', 'transactions', payload=txn_payload, + ) + + transaction_id = txn_result.get('id', '') + self.poynt_transaction_id = transaction_id + self.provider_reference = transaction_id + + return { + 'order_id': order_id, + 'transaction_id': transaction_id, + 'status': txn_result.get('status', ''), + 'funding_source': txn_result.get('fundingSource', {}), + } + except ValidationError as e: + self._set_error(str(e)) + return {} + + def _poynt_process_token_payment(self): + """Process a payment using a stored token (card on file). + + For token-based payments we send a SALE or AUTHORIZE using the + stored card ID from the payment token. + """ + try: + action = 'AUTHORIZE' if self.provider_id.capture_manually else 'SALE' + + funding_source = { + 'type': 'CREDIT_DEBIT', + 'card': { + 'cardId': self.token_id.poynt_card_id, + }, + } + + order_payload = poynt_utils.build_order_payload( + self.reference, self.amount, self.currency_id, + ) + order_result = self.provider_id._poynt_make_request( + 'POST', 'orders', payload=order_payload, + ) + order_id = order_result.get('id', '') + self.poynt_order_id = order_id + + txn_payload = poynt_utils.build_transaction_payload( + action=action, + amount=self.amount, + currency=self.currency_id, + order_id=order_id, + reference=self.reference, + funding_source=funding_source, + ) + txn_result = self.provider_id._poynt_make_request( + 'POST', 'transactions', payload=txn_payload, + ) + + transaction_id = txn_result.get('id', '') + self.poynt_transaction_id = transaction_id + self.provider_reference = transaction_id + + payment_data = { + 'reference': self.reference, + 'poynt_order_id': order_id, + 'poynt_transaction_id': transaction_id, + 'poynt_status': txn_result.get('status', ''), + 'funding_source': txn_result.get('fundingSource', {}), + } + self._process('poynt', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_refund_request(self): + """Override of `payment` to send a refund request to Poynt.""" + if self.provider_code != 'poynt': + return super()._send_refund_request() + + source_tx = self.source_transaction_id + refund_amount = abs(self.amount) + minor_amount = poynt_utils.format_poynt_amount(refund_amount, self.currency_id) + + try: + refund_payload = { + 'action': 'REFUND', + 'parentId': source_tx.provider_reference, + 'amounts': { + 'transactionAmount': minor_amount, + 'orderAmount': minor_amount, + 'currency': self.currency_id.name, + }, + 'context': { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + }, + 'notes': f'Refund for {source_tx.reference}', + } + + result = self.provider_id._poynt_make_request( + 'POST', 'transactions', payload=refund_payload, + ) + + self.provider_reference = result.get('id', '') + self.poynt_transaction_id = result.get('id', '') + + payment_data = { + 'reference': self.reference, + 'poynt_transaction_id': result.get('id'), + 'poynt_status': result.get('status', 'REFUNDED'), + } + self._process('poynt', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_capture_request(self): + """Override of `payment` to send a capture request to Poynt.""" + if self.provider_code != 'poynt': + return super()._send_capture_request() + + source_tx = self.source_transaction_id + minor_amount = poynt_utils.format_poynt_amount(self.amount, self.currency_id) + + try: + capture_payload = { + 'action': 'CAPTURE', + 'parentId': source_tx.provider_reference, + 'amounts': { + 'transactionAmount': minor_amount, + 'orderAmount': minor_amount, + 'currency': self.currency_id.name, + }, + 'context': { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + }, + } + + result = self.provider_id._poynt_make_request( + 'POST', 'transactions', payload=capture_payload, + ) + + payment_data = { + 'reference': self.reference, + 'poynt_transaction_id': result.get('id'), + 'poynt_status': result.get('status', 'CAPTURED'), + } + self._process('poynt', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + def _send_void_request(self): + """Override of `payment` to send a void request to Poynt.""" + if self.provider_code != 'poynt': + return super()._send_void_request() + + source_tx = self.source_transaction_id + + try: + void_payload = { + 'action': 'VOID', + 'parentId': source_tx.provider_reference, + 'context': { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + }, + } + + result = self.provider_id._poynt_make_request( + 'POST', 'transactions', payload=void_payload, + ) + + payment_data = { + 'reference': self.reference, + 'poynt_transaction_id': result.get('id'), + 'poynt_status': result.get('status', 'VOIDED'), + } + self._process('poynt', payment_data) + except ValidationError as e: + self._set_error(str(e)) + + # === BUSINESS METHODS - NOTIFICATION PROCESSING === # + + @api.model + def _search_by_reference(self, provider_code, payment_data): + """Override of payment to find the transaction based on Poynt data.""" + if provider_code != 'poynt': + return super()._search_by_reference(provider_code, payment_data) + + reference = payment_data.get('reference') + if reference: + tx = self.search([ + ('reference', '=', reference), + ('provider_code', '=', 'poynt'), + ]) + else: + poynt_txn_id = payment_data.get('poynt_transaction_id') + if poynt_txn_id: + tx = self.search([ + ('poynt_transaction_id', '=', poynt_txn_id), + ('provider_code', '=', 'poynt'), + ]) + else: + _logger.warning("Received Poynt data with no reference or transaction ID") + tx = self + + if not tx: + _logger.warning( + "No transaction found matching Poynt reference %s", reference, + ) + + return tx + + def _apply_updates(self, payment_data): + """Override of `payment` to update the transaction based on Poynt data.""" + if self.provider_code != 'poynt': + return super()._apply_updates(payment_data) + + poynt_txn_id = payment_data.get('poynt_transaction_id') + if poynt_txn_id: + self.provider_reference = poynt_txn_id + self.poynt_transaction_id = poynt_txn_id + + poynt_order_id = payment_data.get('poynt_order_id') + if poynt_order_id: + self.poynt_order_id = poynt_order_id + + funding_source = payment_data.get('funding_source', {}) + if funding_source: + card_details = poynt_utils.extract_card_details(funding_source) + if card_details.get('brand'): + payment_method = self.env['payment.method']._get_from_code( + card_details['brand'], + mapping=const.CARD_BRAND_MAPPING, + ) + if payment_method: + self.payment_method_id = payment_method + + status = payment_data.get('poynt_status', '') + if not status: + self._set_error(_("Received data with missing transaction status.")) + return + + odoo_state = poynt_utils.get_poynt_status(status) + + if odoo_state == 'authorized': + self._set_authorized() + elif odoo_state == 'done': + self._set_done() + if self.operation == 'refund': + self.env.ref('payment.cron_post_process_payment_tx')._trigger() + elif odoo_state == 'cancel': + self._set_canceled() + elif odoo_state == 'refund': + self._set_done() + self.env.ref('payment.cron_post_process_payment_tx')._trigger() + elif odoo_state == 'error': + error_msg = payment_data.get('error_message', _("Payment was declined by Poynt.")) + self._set_error(error_msg) + else: + _logger.warning( + "Received unknown Poynt status (%s) for transaction %s.", + status, self.reference, + ) + self._set_error( + _("Received data with unrecognized status: %s.", status) + ) + + def _extract_token_values(self, payment_data): + """Override of `payment` to return token data based on Poynt data.""" + if self.provider_code != 'poynt': + return super()._extract_token_values(payment_data) + + funding_source = payment_data.get('funding_source', {}) + card_details = poynt_utils.extract_card_details(funding_source) + + if not card_details: + _logger.warning( + "Tokenization requested but no card data in payment response." + ) + return {} + + return { + 'payment_details': card_details.get('last4', ''), + 'poynt_card_id': card_details.get('card_id', ''), + } diff --git a/fusion_poynt/models/poynt_terminal.py b/fusion_poynt/models/poynt_terminal.py new file mode 100644 index 0000000..35bc2b1 --- /dev/null +++ b/fusion_poynt/models/poynt_terminal.py @@ -0,0 +1,202 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.fusion_poynt import utils as poynt_utils + +_logger = logging.getLogger(__name__) + + +class PoyntTerminal(models.Model): + _name = 'poynt.terminal' + _description = 'Poynt Terminal Device' + _order = 'name' + + name = fields.Char( + string="Terminal Name", + required=True, + ) + device_id = fields.Char( + string="Device ID", + help="The Poynt device identifier (urn:tid:...).", + required=True, + copy=False, + ) + serial_number = fields.Char( + string="Serial Number", + copy=False, + ) + provider_id = fields.Many2one( + 'payment.provider', + string="Payment Provider", + required=True, + ondelete='cascade', + domain="[('code', '=', 'poynt')]", + ) + store_id_poynt = fields.Char( + string="Poynt Store ID", + help="The Poynt store UUID this terminal belongs to.", + ) + status = fields.Selection( + selection=[ + ('online', "Online"), + ('offline', "Offline"), + ('unknown', "Unknown"), + ], + string="Status", + default='unknown', + readonly=True, + ) + last_seen = fields.Datetime( + string="Last Seen", + readonly=True, + ) + active = fields.Boolean( + default=True, + ) + + _unique_device_provider = models.Constraint( + 'UNIQUE(device_id, provider_id)', + 'A terminal with this device ID already exists for this provider.', + ) + + # === BUSINESS METHODS === # + + def action_refresh_status(self): + """Refresh the terminal status from Poynt Cloud.""" + for terminal in self: + try: + store_id = terminal.store_id_poynt or terminal.provider_id.poynt_store_id + if store_id: + endpoint = f'stores/{store_id}/storeDevices/{terminal.device_id}' + else: + endpoint = f'storeDevices/{terminal.device_id}' + + result = terminal.provider_id._poynt_make_request('GET', endpoint) + poynt_status = result.get('status', 'UNKNOWN') + + if poynt_status == 'ACTIVATED': + terminal.status = 'online' + elif poynt_status in ('DEACTIVATED', 'INACTIVE'): + terminal.status = 'offline' + else: + terminal.status = 'unknown' + + terminal.last_seen = fields.Datetime.now() + except (ValidationError, UserError) as e: + _logger.warning( + "Failed to refresh status for terminal %s: %s", + terminal.device_id, e, + ) + terminal.status = 'unknown' + + def action_send_payment_to_terminal(self, amount, currency, reference, order_id=None): + """Push a payment request to the physical Poynt terminal. + + This sends a cloud message to the terminal device instructing it + to start a payment collection for the given amount. + + :param float amount: The payment amount in major currency units. + :param recordset currency: The currency record. + :param str reference: The Odoo payment reference. + :param str order_id: Optional Poynt order UUID to link. + :return: The Poynt cloud message response. + :rtype: dict + :raises UserError: If the terminal is offline. + """ + self.ensure_one() + + if self.status == 'offline': + raise UserError( + _("Terminal '%(name)s' is offline. Please check the device.", + name=self.name) + ) + + minor_amount = poynt_utils.format_poynt_amount(amount, currency) + + payment_request = { + 'amount': minor_amount, + 'currency': currency.name, + 'referenceId': reference, + 'callbackUrl': self._get_terminal_callback_url(), + 'skipReceiptScreen': False, + 'debit': True, + } + + if order_id: + payment_request['orderId'] = order_id + + try: + result = self.provider_id._poynt_make_request( + 'POST', + f'cloudMessages', + payload={ + 'deviceId': self.device_id, + 'ttl': 300, + 'serialNum': self.serial_number or '', + 'data': { + 'action': 'sale', + 'purchaseAmount': minor_amount, + 'tipAmount': 0, + 'currency': currency.name, + 'referenceId': reference, + 'callbackUrl': self._get_terminal_callback_url(), + }, + }, + ) + _logger.info( + "Payment request sent to terminal %s for %s %s (ref: %s)", + self.device_id, amount, currency.name, reference, + ) + return result + except (ValidationError, UserError) as e: + _logger.error( + "Failed to send payment to terminal %s: %s", + self.device_id, e, + ) + raise + + def _get_terminal_callback_url(self): + """Build the callback URL for terminal payment completion. + + :return: The full callback URL. + :rtype: str + """ + base_url = self.provider_id.get_base_url() + return f"{base_url}/payment/poynt/terminal/callback" + + def action_check_terminal_payment_status(self, reference): + """Poll for the status of a terminal payment. + + :param str reference: The Odoo transaction reference. + :return: Dict with status and transaction data if completed. + :rtype: dict + """ + self.ensure_one() + + try: + txn_result = self.provider_id._poynt_make_request( + 'GET', + 'transactions', + params={ + 'notes': reference, + 'limit': 1, + }, + ) + + transactions = txn_result.get('transactions', []) + if not transactions: + return {'status': 'pending', 'message': 'Waiting for terminal response...'} + + txn = transactions[0] + return { + 'status': txn.get('status', 'UNKNOWN'), + 'transaction_id': txn.get('id', ''), + 'funding_source': txn.get('fundingSource', {}), + 'amounts': txn.get('amounts', {}), + } + except (ValidationError, UserError): + return {'status': 'error', 'message': 'Failed to check payment status.'} diff --git a/fusion_poynt/security/ir.model.access.csv b/fusion_poynt/security/ir.model.access.csv new file mode 100644 index 0000000..0f7529a --- /dev/null +++ b/fusion_poynt/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_poynt_terminal_user,poynt.terminal.user,model_poynt_terminal,base.group_user,1,0,0,0 +access_poynt_terminal_admin,poynt.terminal.admin,model_poynt_terminal,base.group_system,1,1,1,1 diff --git a/fusion_poynt/static/description/icon.png b/fusion_poynt/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..73b38d5d1f3b68da3e0bf7029aa6e43d0361e909 GIT binary patch literal 49603 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 Bk8A({ literal 0 HcmV?d00001 diff --git a/fusion_poynt/static/src/interactions/payment_form.js b/fusion_poynt/static/src/interactions/payment_form.js new file mode 100644 index 0000000..d4d23d6 --- /dev/null +++ b/fusion_poynt/static/src/interactions/payment_form.js @@ -0,0 +1,374 @@ +/** @odoo-module **/ + +import { _t } from '@web/core/l10n/translation'; +import { patch } from '@web/core/utils/patch'; +import { rpc } from '@web/core/network/rpc'; + +import { PaymentForm } from '@payment/interactions/payment_form'; + +patch(PaymentForm.prototype, { + + setup() { + super.setup(); + this.poyntFormData = {}; + }, + + // #=== DOM MANIPULATION ===# + + async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) { + if (providerCode !== 'poynt') { + await super._prepareInlineForm(...arguments); + return; + } + + if (flow === 'token') { + return; + } + + this._setPaymentFlow('direct'); + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const poyntContainer = inlineForm.querySelector('[name="o_poynt_payment_container"]'); + + if (!poyntContainer) { + return; + } + + const rawValues = poyntContainer.dataset['poyntInlineFormValues']; + if (rawValues) { + this.poyntFormData = JSON.parse(rawValues); + } + + this._setupCardFormatting(poyntContainer); + this._setupTerminalToggle(poyntContainer); + }, + + _setupCardFormatting(container) { + const cardInput = container.querySelector('#poynt_card_number'); + if (cardInput) { + cardInput.addEventListener('input', (e) => { + let value = e.target.value.replace(/\D/g, ''); + let formatted = ''; + for (let i = 0; i < value.length && i < 16; i++) { + if (i > 0 && i % 4 === 0) { + formatted += ' '; + } + formatted += value[i]; + } + e.target.value = formatted; + }); + } + + const expiryInput = container.querySelector('#poynt_expiry'); + if (expiryInput) { + expiryInput.addEventListener('input', (e) => { + let value = e.target.value.replace(/\D/g, ''); + if (value.length >= 2) { + value = value.substring(0, 2) + '/' + value.substring(2, 4); + } + e.target.value = value; + }); + } + }, + + _setupTerminalToggle(container) { + const terminalCheckbox = container.querySelector('#poynt_use_terminal'); + const terminalSelect = container.querySelector('#poynt_terminal_select_wrapper'); + const cardFields = container.querySelectorAll( + '#poynt_card_number, #poynt_expiry, #poynt_cvv, #poynt_cardholder' + ); + + if (!terminalCheckbox) { + return; + } + + terminalCheckbox.addEventListener('change', () => { + if (terminalCheckbox.checked) { + if (terminalSelect) { + terminalSelect.style.display = 'block'; + } + cardFields.forEach(f => { + f.closest('.mb-3').style.display = 'none'; + f.removeAttribute('required'); + }); + this._loadTerminals(container); + } else { + if (terminalSelect) { + terminalSelect.style.display = 'none'; + } + cardFields.forEach(f => { + f.closest('.mb-3').style.display = 'block'; + if (f.id !== 'poynt_cardholder') { + f.setAttribute('required', 'required'); + } + }); + } + }); + }, + + async _loadTerminals(container) { + const selectEl = container.querySelector('#poynt_terminal_select'); + if (!selectEl || selectEl.options.length > 1) { + return; + } + + try { + const terminals = await rpc('/payment/poynt/terminals', { + provider_id: this.poyntFormData.provider_id, + }); + + selectEl.innerHTML = ''; + if (terminals && terminals.length > 0) { + terminals.forEach(t => { + const option = document.createElement('option'); + option.value = t.id; + option.textContent = `${t.name} (${t.status})`; + selectEl.appendChild(option); + }); + } else { + const option = document.createElement('option'); + option.value = ''; + option.textContent = _t('No terminals available'); + selectEl.appendChild(option); + } + } catch { + const option = document.createElement('option'); + option.value = ''; + option.textContent = _t('Failed to load terminals'); + selectEl.appendChild(option); + } + }, + + // #=== PAYMENT FLOW ===# + + async _initiatePaymentFlow(providerCode, paymentOptionId, paymentMethodCode, flow) { + if (providerCode !== 'poynt' || flow === 'token') { + await super._initiatePaymentFlow(...arguments); + return; + } + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const useTerminal = inlineForm.querySelector('#poynt_use_terminal'); + + if (useTerminal && useTerminal.checked) { + const terminalId = inlineForm.querySelector('#poynt_terminal_select').value; + if (!terminalId) { + this._displayErrorDialog( + _t("Terminal Required"), + _t("Please select a terminal device."), + ); + this._enableButton(); + return; + } + } else { + const validationError = this._validateCardInputs(inlineForm); + if (validationError) { + this._displayErrorDialog( + _t("Invalid Card Details"), + validationError, + ); + this._enableButton(); + return; + } + } + + await super._initiatePaymentFlow(...arguments); + }, + + _validateCardInputs(inlineForm) { + const cardNumber = inlineForm.querySelector('#poynt_card_number'); + const expiry = inlineForm.querySelector('#poynt_expiry'); + const cvv = inlineForm.querySelector('#poynt_cvv'); + + const cardDigits = cardNumber.value.replace(/\D/g, ''); + if (cardDigits.length < 13 || cardDigits.length > 19) { + return _t("Please enter a valid card number."); + } + + const expiryValue = expiry.value; + if (!/^\d{2}\/\d{2}$/.test(expiryValue)) { + return _t("Please enter a valid expiry date (MM/YY)."); + } + + const [month, year] = expiryValue.split('/').map(Number); + if (month < 1 || month > 12) { + return _t("Invalid expiry month."); + } + + const now = new Date(); + const expiryDate = new Date(2000 + year, month); + if (expiryDate <= now) { + return _t("Card has expired."); + } + + const cvvValue = cvv.value.replace(/\D/g, ''); + if (cvvValue.length < 3 || cvvValue.length > 4) { + return _t("Please enter a valid CVV."); + } + + return null; + }, + + async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) { + if (providerCode !== 'poynt') { + await super._processDirectFlow(...arguments); + return; + } + + const radio = document.querySelector('input[name="o_payment_radio"]:checked'); + const inlineForm = this._getInlineForm(radio); + const useTerminal = inlineForm.querySelector('#poynt_use_terminal'); + + if (useTerminal && useTerminal.checked) { + await this._processTerminalPayment(processingValues, inlineForm); + } else { + await this._processCardPayment(processingValues, inlineForm); + } + }, + + async _processCardPayment(processingValues, inlineForm) { + const cardNumber = inlineForm.querySelector('#poynt_card_number').value.replace(/\D/g, ''); + const expiry = inlineForm.querySelector('#poynt_expiry').value; + const cvv = inlineForm.querySelector('#poynt_cvv').value; + const cardholder = inlineForm.querySelector('#poynt_cardholder').value; + + const [expMonth, expYear] = expiry.split('/').map(Number); + + try { + const result = await rpc('/payment/poynt/process_card', { + reference: processingValues.reference, + poynt_order_id: processingValues.poynt_order_id, + card_number: cardNumber, + exp_month: expMonth, + exp_year: 2000 + expYear, + cvv: cvv, + cardholder_name: cardholder, + }); + + if (result.error) { + this._displayErrorDialog( + _t("Payment Failed"), + result.error, + ); + this._enableButton(); + return; + } + + window.location.href = processingValues.return_url; + } catch (error) { + this._displayErrorDialog( + _t("Payment Processing Error"), + error.message || _t("An unexpected error occurred."), + ); + this._enableButton(); + } + }, + + async _processTerminalPayment(processingValues, inlineForm) { + const terminalId = inlineForm.querySelector('#poynt_terminal_select').value; + + try { + const result = await rpc('/payment/poynt/send_to_terminal', { + reference: processingValues.reference, + terminal_id: parseInt(terminalId), + poynt_order_id: processingValues.poynt_order_id, + }); + + if (result.error) { + this._displayErrorDialog( + _t("Terminal Payment Failed"), + result.error, + ); + this._enableButton(); + return; + } + + this._showTerminalWaitingScreen(processingValues, terminalId); + } catch (error) { + this._displayErrorDialog( + _t("Terminal Error"), + error.message || _t("Failed to send payment to terminal."), + ); + this._enableButton(); + } + }, + + _showTerminalWaitingScreen(processingValues, terminalId) { + const container = document.querySelector('.o_poynt_payment_form'); + if (container) { + container.innerHTML = ` +
+
+ Loading... +
+
${_t("Waiting for terminal payment...")}
+

+ ${_t("Please complete the payment on the terminal device.")} +

+

+ ${_t("Checking status...")} +

+
+ `; + } + + this._pollTerminalStatus(processingValues, terminalId); + }, + + async _pollTerminalStatus(processingValues, terminalId, attempt = 0) { + const maxAttempts = 60; + const pollInterval = 3000; + + if (attempt >= maxAttempts) { + this._displayErrorDialog( + _t("Timeout"), + _t("Terminal payment timed out. Please check the device."), + ); + this._enableButton(); + return; + } + + try { + const result = await rpc('/payment/poynt/terminal_status', { + reference: processingValues.reference, + terminal_id: parseInt(terminalId), + }); + + const statusEl = document.getElementById('poynt_terminal_status'); + + if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') { + if (statusEl) { + statusEl.textContent = _t("Payment completed! Redirecting..."); + } + window.location.href = processingValues.return_url; + return; + } + + if (result.status === 'DECLINED' || result.status === 'FAILED') { + this._displayErrorDialog( + _t("Payment Declined"), + _t("The payment was declined at the terminal."), + ); + this._enableButton(); + return; + } + + if (statusEl) { + statusEl.textContent = _t("Status: ") + (result.status || _t("Pending")); + } + + setTimeout( + () => this._pollTerminalStatus(processingValues, terminalId, attempt + 1), + pollInterval, + ); + } catch { + setTimeout( + () => this._pollTerminalStatus(processingValues, terminalId, attempt + 1), + pollInterval, + ); + } + }, + +}); diff --git a/fusion_poynt/static/src/interactions/terminal_payment.js b/fusion_poynt/static/src/interactions/terminal_payment.js new file mode 100644 index 0000000..e0d43a8 --- /dev/null +++ b/fusion_poynt/static/src/interactions/terminal_payment.js @@ -0,0 +1,136 @@ +/** @odoo-module **/ + +import { _t } from '@web/core/l10n/translation'; +import { rpc } from '@web/core/network/rpc'; +import { Component, useState } from '@odoo/owl'; + +export class TerminalPaymentWidget extends Component { + static template = 'fusion_poynt.TerminalPaymentWidget'; + static props = { + providerId: { type: Number }, + amount: { type: Number }, + currency: { type: String }, + reference: { type: String }, + orderId: { type: String, optional: true }, + onComplete: { type: Function, optional: true }, + onError: { type: Function, optional: true }, + }; + + setup() { + this.state = useState({ + terminals: [], + selectedTerminalId: null, + loading: false, + polling: false, + status: '', + message: '', + }); + this._loadTerminals(); + } + + async _loadTerminals() { + this.state.loading = true; + try { + const result = await rpc('/payment/poynt/terminals', { + provider_id: this.props.providerId, + }); + this.state.terminals = result || []; + if (this.state.terminals.length > 0) { + this.state.selectedTerminalId = this.state.terminals[0].id; + } + } catch { + this.state.message = _t('Failed to load terminal devices.'); + } finally { + this.state.loading = false; + } + } + + onTerminalChange(ev) { + this.state.selectedTerminalId = parseInt(ev.target.value); + } + + async onSendToTerminal() { + if (!this.state.selectedTerminalId) { + this.state.message = _t('Please select a terminal.'); + return; + } + + this.state.loading = true; + this.state.message = ''; + + try { + const result = await rpc('/payment/poynt/send_to_terminal', { + reference: this.props.reference, + terminal_id: this.state.selectedTerminalId, + poynt_order_id: this.props.orderId || '', + }); + + if (result.error) { + this.state.message = result.error; + this.state.loading = false; + if (this.props.onError) { + this.props.onError(result.error); + } + return; + } + + this.state.polling = true; + this.state.status = _t('Waiting for payment on terminal...'); + this._pollStatus(0); + } catch (error) { + this.state.message = error.message || _t('Failed to send payment to terminal.'); + this.state.loading = false; + if (this.props.onError) { + this.props.onError(this.state.message); + } + } + } + + async _pollStatus(attempt) { + const maxAttempts = 60; + const pollInterval = 3000; + + if (attempt >= maxAttempts) { + this.state.polling = false; + this.state.loading = false; + this.state.message = _t('Payment timed out. Please check the terminal.'); + if (this.props.onError) { + this.props.onError(this.state.message); + } + return; + } + + try { + const result = await rpc('/payment/poynt/terminal_status', { + reference: this.props.reference, + terminal_id: this.state.selectedTerminalId, + }); + + if (result.status === 'CAPTURED' || result.status === 'AUTHORIZED') { + this.state.polling = false; + this.state.loading = false; + this.state.status = _t('Payment completed!'); + if (this.props.onComplete) { + this.props.onComplete(result); + } + return; + } + + if (result.status === 'DECLINED' || result.status === 'FAILED') { + this.state.polling = false; + this.state.loading = false; + this.state.message = _t('Payment was declined.'); + if (this.props.onError) { + this.props.onError(this.state.message); + } + return; + } + + this.state.status = _t('Status: ') + (result.status || _t('Pending')); + } catch { + this.state.status = _t('Checking...'); + } + + setTimeout(() => this._pollStatus(attempt + 1), pollInterval); + } +} diff --git a/fusion_poynt/utils.py b/fusion_poynt/utils.py new file mode 100644 index 0000000..4d57f65 --- /dev/null +++ b/fusion_poynt/utils.py @@ -0,0 +1,251 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json +import time +import uuid + +from odoo.exceptions import ValidationError + +from odoo.addons.fusion_poynt import const + + +def generate_request_id(): + """Generate a unique request ID for Poynt API idempotency.""" + return str(uuid.uuid4()) + + +def build_api_url(endpoint, business_id=None, store_id=None, is_test=False): + """Build a full Poynt API URL for the given endpoint. + + :param str endpoint: The API endpoint path (e.g., 'orders', 'transactions'). + :param str business_id: The merchant's business UUID. + :param str store_id: The store UUID (optional, for store-scoped endpoints). + :param bool is_test: Whether to use the test environment. + :return: The full API URL. + :rtype: str + """ + base = const.API_BASE_URL_TEST if is_test else const.API_BASE_URL + + if business_id and store_id: + return f"{base}/businesses/{business_id}/stores/{store_id}/{endpoint}" + elif business_id: + return f"{base}/businesses/{business_id}/{endpoint}" + return f"{base}/{endpoint}" + + +def build_api_headers(access_token, request_id=None): + """Build the standard HTTP headers for a Poynt API request. + + :param str access_token: The OAuth2 bearer token. + :param str request_id: Optional unique request ID for idempotency. + :return: The request headers dict. + :rtype: dict + """ + headers = { + 'Content-Type': 'application/json', + 'Api-Version': const.API_VERSION, + 'Authorization': f'Bearer {access_token}', + } + if request_id: + headers['POYNT-REQUEST-ID'] = request_id + return headers + + +def create_self_signed_jwt(application_id, private_key_pem): + """Create a self-signed JWT for Poynt OAuth2 token request. + + The JWT is signed with the application's RSA private key and used + as the assertion in the JWT bearer grant type flow. + + :param str application_id: The Poynt application ID (urn:aid:...). + :param str private_key_pem: PEM-encoded RSA private key string. + :return: The signed JWT string. + :rtype: str + :raises ValidationError: If JWT creation fails. + """ + try: + import jwt as pyjwt + from cryptography.hazmat.primitives.serialization import load_pem_private_key + from cryptography.hazmat.backends import default_backend + except ImportError: + raise ValidationError( + "Required Python packages 'PyJWT' and 'cryptography' are not installed. " + "Install them with: pip install PyJWT cryptography" + ) + + try: + if isinstance(private_key_pem, bytes): + key_bytes = private_key_pem + else: + key_bytes = private_key_pem.encode('utf-8') + + private_key = load_pem_private_key(key_bytes, password=None, backend=default_backend()) + + now = int(time.time()) + payload = { + 'iss': application_id, + 'sub': application_id, + 'aud': 'https://services.poynt.net', + 'iat': now, + 'exp': now + 300, + 'jti': str(uuid.uuid4()), + } + + token = pyjwt.encode(payload, private_key, algorithm='RS256') + return token + except Exception as e: + raise ValidationError( + f"Failed to create self-signed JWT for Poynt authentication: {e}" + ) + + +def format_poynt_amount(amount, currency): + """Convert a major currency amount to Poynt's minor units (cents). + + :param float amount: The amount in major currency units. + :param recordset currency: The currency record. + :return: The amount in minor currency units (integer). + :rtype: int + """ + decimals = const.CURRENCY_DECIMALS.get(currency.name, 2) + return int(round(amount * (10 ** decimals))) + + +def parse_poynt_amount(minor_amount, currency): + """Convert Poynt's minor currency units back to major units. + + :param int minor_amount: The amount in minor currency units. + :param recordset currency: The currency record. + :return: The amount in major currency units. + :rtype: float + """ + decimals = const.CURRENCY_DECIMALS.get(currency.name, 2) + return minor_amount / (10 ** decimals) + + +def extract_card_details(funding_source): + """Extract card details from a Poynt funding source object. + + :param dict funding_source: The Poynt fundingSource object from a transaction. + :return: Dict with card brand, last4, expiration, and card type. + :rtype: dict + """ + if not funding_source or 'card' not in funding_source: + return {} + + card = funding_source['card'] + brand_code = const.CARD_BRAND_MAPPING.get( + card.get('type', ''), 'card' + ) + + return { + 'brand': brand_code, + 'last4': card.get('numberLast4', ''), + 'exp_month': card.get('expirationMonth'), + 'exp_year': card.get('expirationYear'), + 'card_holder': card.get('cardHolderFullName', ''), + 'card_id': card.get('cardId', ''), + 'number_first6': card.get('numberFirst6', ''), + } + + +def get_poynt_status(status_str): + """Map a Poynt transaction status string to an Odoo transaction state. + + :param str status_str: The Poynt transaction status. + :return: The corresponding Odoo payment state. + :rtype: str + """ + for odoo_state, poynt_statuses in const.STATUS_MAPPING.items(): + if status_str in poynt_statuses: + return odoo_state + return 'error' + + +def build_order_payload(reference, amount, currency, items=None, notes=''): + """Build a Poynt order creation payload. + + :param str reference: The Odoo transaction reference. + :param float amount: The order total in major currency units. + :param recordset currency: The currency record. + :param list items: Optional list of order item dicts. + :param str notes: Optional order notes. + :return: The Poynt-formatted order payload. + :rtype: dict + """ + minor_amount = format_poynt_amount(amount, currency) + + if not items: + items = [{ + 'name': reference, + 'quantity': 1, + 'unitPrice': minor_amount, + 'tax': 0, + 'status': 'ORDERED', + 'unitOfMeasure': 'EACH', + }] + + return { + 'items': items, + 'amounts': { + 'subTotal': minor_amount, + 'discountTotal': 0, + 'feeTotal': 0, + 'taxTotal': 0, + 'netTotal': minor_amount, + 'currency': currency.name, + }, + 'context': { + 'source': 'WEB', + 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', + }, + 'statuses': { + 'status': 'OPENED', + }, + 'notes': notes or reference, + } + + +def build_transaction_payload( + action, amount, currency, order_id=None, reference='', funding_source=None +): + """Build a Poynt transaction payload for charge/auth/capture. + + :param str action: The transaction action (AUTHORIZE, SALE, CAPTURE, etc.). + :param float amount: The amount in major currency units. + :param recordset currency: The currency record. + :param str order_id: The Poynt order UUID (optional). + :param str reference: The Odoo transaction reference. + :param dict funding_source: The funding source / card data (optional). + :return: The Poynt-formatted transaction payload. + :rtype: dict + """ + minor_amount = format_poynt_amount(amount, currency) + + payload = { + 'action': action, + 'amounts': { + 'transactionAmount': minor_amount, + 'orderAmount': minor_amount, + 'tipAmount': 0, + 'cashbackAmount': 0, + 'currency': currency.name, + }, + 'context': { + 'source': 'WEB', + 'sourceApp': 'odoo.fusion_poynt', + 'transactionInstruction': 'ONLINE_AUTH_REQUIRED', + }, + 'notes': reference, + } + + if order_id: + payload['references'] = [{ + 'id': order_id, + 'type': 'POYNT_ORDER', + }] + + if funding_source: + payload['fundingSource'] = funding_source + + return payload diff --git a/fusion_poynt/views/payment_poynt_templates.xml b/fusion_poynt/views/payment_poynt_templates.xml new file mode 100644 index 0000000..02ada33 --- /dev/null +++ b/fusion_poynt/views/payment_poynt_templates.xml @@ -0,0 +1,84 @@ + + + + + + + diff --git a/fusion_poynt/views/payment_provider_views.xml b/fusion_poynt/views/payment_provider_views.xml new file mode 100644 index 0000000..dc10bf4 --- /dev/null +++ b/fusion_poynt/views/payment_provider_views.xml @@ -0,0 +1,50 @@ + + + + + Poynt Provider Form + payment.provider + + + + + + + + + + + + +