Skip to content

Commit 6ddd7f6

Browse files
authored
[codex] Add self-service ERP payment info command
1 parent b9cd975 commit 6ddd7f6

4 files changed

Lines changed: 1442 additions & 0 deletions

File tree

apps/discord_bot/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ Relevant configuration:
138138
- Returns an ephemeral one-time URL with expiry.
139139
- Opening the URL loads a short browser page that automatically continues with a POST. The link is not consumed by the initial GET, so normal Discord link previews and security scanners do not burn the token.
140140

141+
- `/payment-info`
142+
- Description: View masked ERPNext Supplier Details payment info and open a modal to update your own payment details.
143+
- Behavior:
144+
- Resolves only the invoking Discord user through CRM `cDiscordUserID`.
145+
- Requires the linked CRM contact to have a matching `@508.dev` email and ERPNext User/Supplier identity.
146+
- Stores updates in the Supplier `supplier_details` field.
147+
- Sends all responses ephemerally.
148+
- Does not accept a user/contact/supplier argument or provide an admin override path.
149+
141150
- `/create-mailbox`
142151
- Description: Create a Migadu mailbox for a 508 user, optionally link it to a CRM contact, and sync `c508Email`.
143152
- Prerequisites: `MIGADU_API_USER` and `MIGADU_API_KEY` must be configured (configured in env; command will fail if missing).
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
"""Self-service ERPNext payment-info Discord command."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import logging
7+
from typing import Any
8+
9+
import discord
10+
from discord import app_commands
11+
from discord.ext import commands
12+
13+
from five08.clients.erpnext import ERPNextAPIError, ERPNextClient
14+
from five08.clients.espo import EspoAPIError, EspoClient
15+
from five08.discord_bot.config import settings
16+
from five08.discord_bot.utils.audit import DiscordAuditCogMixin
17+
from five08.payment_info import (
18+
PaymentIdentity,
19+
PaymentInfoError,
20+
PaymentInfoInput,
21+
get_supplier_payment_details,
22+
normalize_508_email,
23+
payment_info_summary,
24+
resolve_payment_identity,
25+
update_supplier_payment_details,
26+
)
27+
28+
logger = logging.getLogger(__name__)
29+
NO_MENTIONS = discord.AllowedMentions.none()
30+
31+
32+
class PaymentInfoModal(discord.ui.Modal, title="Update Payment Info"):
33+
"""Modal for self-service payment-info updates."""
34+
35+
supplier_details: discord.ui.TextInput = discord.ui.TextInput(
36+
label="Supplier Details",
37+
placeholder="Paste the Supplier Details payment text here.",
38+
required=True,
39+
style=discord.TextStyle.paragraph,
40+
max_length=4000,
41+
)
42+
43+
def __init__(self, cog: "PaymentInfoCog", owner_user_id: str) -> None:
44+
super().__init__(timeout=300)
45+
self.cog = cog
46+
self.owner_user_id = owner_user_id
47+
48+
async def on_submit(self, interaction: discord.Interaction) -> None:
49+
if str(interaction.user.id) != self.owner_user_id:
50+
await interaction.response.send_message(
51+
"This payment-info form belongs to another Discord user.",
52+
allowed_mentions=NO_MENTIONS,
53+
ephemeral=True,
54+
)
55+
return
56+
57+
await interaction.response.defer(ephemeral=True)
58+
await self.cog.handle_payment_info_update(
59+
interaction,
60+
PaymentInfoInput(
61+
supplier_details=str(self.supplier_details.value or ""),
62+
),
63+
)
64+
65+
66+
class PaymentInfoView(discord.ui.View):
67+
"""Ephemeral view with a self-only update button."""
68+
69+
def __init__(self, cog: "PaymentInfoCog", owner_user_id: str) -> None:
70+
super().__init__(timeout=300)
71+
self.cog = cog
72+
self.owner_user_id = owner_user_id
73+
74+
@discord.ui.button(
75+
label="Update Payment Info",
76+
style=discord.ButtonStyle.primary,
77+
custom_id="payment_info_update_self",
78+
)
79+
async def update_payment_info(
80+
self,
81+
interaction: discord.Interaction,
82+
_button: discord.ui.Button["PaymentInfoView"],
83+
) -> None:
84+
if str(interaction.user.id) != self.owner_user_id:
85+
await interaction.response.send_message(
86+
"This payment-info view belongs to another Discord user.",
87+
allowed_mentions=NO_MENTIONS,
88+
ephemeral=True,
89+
)
90+
return
91+
await interaction.response.send_modal(
92+
PaymentInfoModal(self.cog, self.owner_user_id)
93+
)
94+
95+
96+
class PaymentInfoCog(DiscordAuditCogMixin, commands.Cog, name="Payment Info"):
97+
"""Cog for users to view and update their own ERPNext payment info."""
98+
99+
def __init__(self, bot: commands.Bot) -> None:
100+
self.bot = bot
101+
self.crm = EspoClient(
102+
settings.espo_base_url,
103+
settings.espo_api_key,
104+
timeout_seconds=20.0,
105+
)
106+
self._init_audit_logger()
107+
logger.info("Payment Info cog initialized")
108+
109+
def _erpnext_client(self) -> ERPNextClient:
110+
base_url = (settings.erpnext_base_url or "").strip()
111+
api_key = (settings.erpnext_api_key or "").strip()
112+
if not base_url or not api_key:
113+
raise ValueError("ERPNext API settings are required.")
114+
return ERPNextClient(
115+
base_url=base_url,
116+
api_key=api_key,
117+
timeout_seconds=settings.erpnext_api_timeout_seconds,
118+
)
119+
120+
def _crm_contact_for_discord_user(
121+
self,
122+
discord_user_id: str,
123+
) -> dict[str, Any]:
124+
response = self.crm.list_contacts(
125+
{
126+
"where": [
127+
{
128+
"type": "equals",
129+
"attribute": "cDiscordUserID",
130+
"value": discord_user_id,
131+
}
132+
],
133+
"maxSize": 2,
134+
"select": "id,name,emailAddress,c508Email,cDiscordUserID",
135+
}
136+
)
137+
contacts = response.get("list", [])
138+
if not isinstance(contacts, list) or not contacts:
139+
raise PaymentInfoError(
140+
"Your Discord account is not linked to a CRM contact yet."
141+
)
142+
if len(contacts) > 1:
143+
raise PaymentInfoError(
144+
"Your Discord account is linked to multiple CRM contacts. "
145+
"Ask the operations team to fix the CRM links first."
146+
)
147+
contact = contacts[0]
148+
if not isinstance(contact, dict):
149+
raise PaymentInfoError("CRM returned an invalid contact record.")
150+
linked_discord_id = str(contact.get("cDiscordUserID") or "").strip()
151+
if linked_discord_id != discord_user_id:
152+
raise PaymentInfoError("CRM Discord link did not match your Discord user.")
153+
return contact
154+
155+
def _read_self_payment_info(
156+
self,
157+
discord_user_id: str,
158+
) -> tuple[dict[str, Any], PaymentIdentity, dict[str, Any]]:
159+
contact = self._crm_contact_for_discord_user(discord_user_id)
160+
email = normalize_508_email(contact.get("c508Email"))
161+
client = self._erpnext_client()
162+
try:
163+
identity = resolve_payment_identity(client, email)
164+
supplier = get_supplier_payment_details(client, identity)
165+
finally:
166+
client.close()
167+
return contact, identity, supplier
168+
169+
def _update_self_payment_info(
170+
self,
171+
discord_user_id: str,
172+
payment_info: PaymentInfoInput,
173+
) -> tuple[dict[str, Any], PaymentIdentity, dict[str, Any], list[str]]:
174+
contact = self._crm_contact_for_discord_user(discord_user_id)
175+
email = normalize_508_email(contact.get("c508Email"))
176+
client = self._erpnext_client()
177+
try:
178+
identity = resolve_payment_identity(client, email)
179+
supplier, changed_fields = update_supplier_payment_details(
180+
client,
181+
identity,
182+
payment_info,
183+
)
184+
finally:
185+
client.close()
186+
return contact, identity, supplier, changed_fields
187+
188+
@app_commands.command(
189+
name="payment-info",
190+
description="View or update your own ERPNext payment info.",
191+
)
192+
async def payment_info_command(self, interaction: discord.Interaction) -> None:
193+
"""Show the invoking user's own payment info, masked."""
194+
await interaction.response.defer(ephemeral=True)
195+
try:
196+
contact, identity, supplier = await asyncio.to_thread(
197+
self._read_self_payment_info,
198+
str(interaction.user.id),
199+
)
200+
except PaymentInfoError as exc:
201+
self._audit_command_safe(
202+
interaction=interaction,
203+
action="erpnext.payment_info_view",
204+
result="denied",
205+
metadata={"error": str(exc)},
206+
resource_type="erpnext_payment_info",
207+
)
208+
await interaction.followup.send(
209+
f"⚠️ {exc}",
210+
allowed_mentions=NO_MENTIONS,
211+
ephemeral=True,
212+
)
213+
return
214+
except (ERPNextAPIError, EspoAPIError, ValueError) as exc:
215+
logger.error("Payment-info lookup failed: %s", exc)
216+
self._audit_command_safe(
217+
interaction=interaction,
218+
action="erpnext.payment_info_view",
219+
result="error",
220+
metadata={"error": str(exc)},
221+
resource_type="erpnext_payment_info",
222+
)
223+
await interaction.followup.send(
224+
"❌ Payment-info lookup failed. Please try again later.",
225+
allowed_mentions=NO_MENTIONS,
226+
ephemeral=True,
227+
)
228+
return
229+
230+
self._audit_command_safe(
231+
interaction=interaction,
232+
action="erpnext.payment_info_view",
233+
result="success",
234+
metadata={
235+
"crm_contact_id": contact.get("id"),
236+
"erpnext_user": identity.email,
237+
"supplier_id": identity.supplier_id,
238+
"has_supplier_details": bool(supplier.get("supplier_details")),
239+
},
240+
resource_type="erpnext_supplier",
241+
resource_id=identity.supplier_id,
242+
)
243+
await interaction.followup.send(
244+
"\n".join(payment_info_summary(identity, supplier)),
245+
view=PaymentInfoView(self, str(interaction.user.id)),
246+
allowed_mentions=NO_MENTIONS,
247+
ephemeral=True,
248+
)
249+
250+
async def handle_payment_info_update(
251+
self,
252+
interaction: discord.Interaction,
253+
payment_info: PaymentInfoInput,
254+
) -> None:
255+
"""Apply a modal payment-info update for the invoking user."""
256+
try:
257+
(
258+
contact,
259+
identity,
260+
supplier,
261+
changed_fields,
262+
) = await asyncio.to_thread(
263+
self._update_self_payment_info,
264+
str(interaction.user.id),
265+
payment_info,
266+
)
267+
except PaymentInfoError as exc:
268+
self._audit_command_safe(
269+
interaction=interaction,
270+
action="erpnext.payment_info_update",
271+
result="denied",
272+
metadata={"error": str(exc)},
273+
resource_type="erpnext_payment_info",
274+
)
275+
await interaction.followup.send(
276+
f"⚠️ {exc}",
277+
allowed_mentions=NO_MENTIONS,
278+
ephemeral=True,
279+
)
280+
return
281+
except (ERPNextAPIError, EspoAPIError, ValueError) as exc:
282+
logger.error("Payment-info update failed: %s", exc)
283+
self._audit_command_safe(
284+
interaction=interaction,
285+
action="erpnext.payment_info_update",
286+
result="error",
287+
metadata={"error": str(exc)},
288+
resource_type="erpnext_payment_info",
289+
)
290+
await interaction.followup.send(
291+
"❌ Payment-info update failed. Please try again later.",
292+
allowed_mentions=NO_MENTIONS,
293+
ephemeral=True,
294+
)
295+
return
296+
297+
self._audit_command_safe(
298+
interaction=interaction,
299+
action="erpnext.payment_info_update",
300+
result="success",
301+
metadata={
302+
"crm_contact_id": contact.get("id"),
303+
"erpnext_user": identity.email,
304+
"supplier_id": identity.supplier_id,
305+
"changed_fields": changed_fields,
306+
},
307+
resource_type="erpnext_supplier",
308+
resource_id=identity.supplier_id,
309+
)
310+
await interaction.followup.send(
311+
"✅ Payment info updated.\n"
312+
+ "\n".join(payment_info_summary(identity, supplier)),
313+
allowed_mentions=NO_MENTIONS,
314+
ephemeral=True,
315+
)
316+
317+
318+
async def setup(bot: commands.Bot) -> None:
319+
if not all(
320+
(
321+
(settings.espo_base_url or "").strip(),
322+
(settings.espo_api_key or "").strip(),
323+
(settings.erpnext_base_url or "").strip(),
324+
(settings.erpnext_api_key or "").strip(),
325+
)
326+
):
327+
logger.warning(
328+
"Payment Info cog not loaded: missing CRM or ERPNext API settings"
329+
)
330+
return
331+
await bot.add_cog(PaymentInfoCog(bot))

0 commit comments

Comments
 (0)