Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ OUTLINE_API_KEY=
ERPNEXT_BASE_URL=https://erp.example.com/
ERPNEXT_API_TIMEOUT_SECONDS=20.0
ERPNEXT_API_KEY=
# Dedicated Frappe Webhook Secret for submitted Bank Transaction notifications.
# This is verified as X-Frappe-Webhook-Signature (base64 HMAC-SHA256); it does
# not fall back to WEBHOOK_SHARED_SECRET or API_SHARED_SECRET.
ERPNEXT_BANK_TRANSACTION_WEBHOOK_SIGNING_SECRET=
# Payment automation flags are intentionally omitted so the protected runtime
# configuration dashboard can manage them. Set any of the following here only
# to lock it to deployment configuration: PROJECT_PAYMENT_AUTOMATION_ENABLED,
# PROJECT_PAYMENT_NOTIFICATIONS_ENABLED, PROJECT_PAYMENT_RECOVERY_INTERVAL_SECONDS.
# Optional: Discord logs webhook for operator-visible logs from commands/jobs
DISCORD_LOGS_WEBHOOK_URL=
# Optional: wait for Discord server confirmation before returning from webhook call
Expand Down
32 changes: 32 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,38 @@ Job state is persisted in Postgres table `jobs` with:
Redis is the delivery transport. Postgres remains the source of truth for
retries, idempotency, and inspection.

Payment-routing automation uses an additional semantic event/action outbox in
Postgres. A signed ERPNext Bank Transaction webhook supplies only a document
identity; the worker fetches canonical ERP data, evaluates typed rules, records
project allocations, and asks the Discord bot (the sole Discord executor) to
deliver an outbox-backed notification to a registered private project channel.
ERPNext remains the accounting source of truth; this service intentionally does
not run a second Plaid transaction sync or store Plaid credentials. Automatic
allocation is a single database transaction that fences the rule version, ERP
revision, open-project state, allocation total, and notification outbox. Lower
confidence matches remain immutable human-review suggestions instead.
An approved suggestion with strong canonical payer/reference evidence can
deterministically add a low-priority learned *suggestion* rule. The rule carries
an opaque provenance fingerprint, is visible and disableable to operators, and
is fail-closed from automatic payment routing even if its stored mode were
tampered with. This gives the system a reviewable feedback loop without
turning a financial decision into opaque autonomous classification.
The worker refreshes the targeted ERP project immediately before any allocation
so an old local `Open` cache entry cannot route a recently closed project.
Immediately before a Discord side effect, it also re-reads the ERP project and
Bank Transaction, requiring the allocation's captured transaction revision; a
closed project, canceled transaction, or revised transaction blocks the delayed
outbox row. When a newer canonical revision is persisted, an existing automatic
configured-rule allocation for the old revision is retained for audit but marked
superseded, which also makes its unsent outbox row ineligible. Human-reviewed,
manual, and ERP-reconciled allocations are not changed automatically. A
correction after a Discord message has succeeded remains an explicit
reconciliation case: v1 does not retract Discord content.
The bot keeps its own durable receipt/lease because the Discord send and worker
acknowledgement occur in separate processes; a periodic recovery job reclaims
stale action, outbox, and bot leases and retries idempotent learned-suggestion
derivation from approved feedback.

Worker schema is managed by Alembic migrations in
`apps/worker/src/five08/worker/migrations`. The Web/API service applies these
migrations on startup through `run_job_migrations()`.
Expand Down
137 changes: 137 additions & 0 deletions apps/admin_dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ import {
newsletterProviderResults,
} from "@/views/newsletter-models"
import { NewsletterView } from "@/views/newsletter-view"
import {
type PaymentAutomationRule,
type PaymentAutomationRuleDraft,
type PaymentAutomationSuggestion,
PaymentAutomationView,
} from "@/views/payment-automation-view"
import "./index.css"

type View =
Expand All @@ -81,6 +87,7 @@ type View =
| "agent"
| "audit"
| "configuration"
| "payments"
type SortDirection = "asc" | "desc"
type GigTab = "gigs" | "leads"

Expand Down Expand Up @@ -481,6 +488,7 @@ const routes: Record<View, string> = {
agent: "/dashboard/agent",
audit: "/dashboard/audit",
configuration: "/dashboard/configuration",
payments: "/dashboard/payments",
}

const routePermissions: Record<View, string> = {
Expand All @@ -493,6 +501,7 @@ const routePermissions: Record<View, string> = {
agent: "audit:read",
audit: "audit:read",
configuration: "configuration:read",
payments: "configuration:read",
}

const peopleFilterDefinitions = {
Expand Down Expand Up @@ -892,6 +901,9 @@ function App() {
const [auditEvents, setAuditEvents] = useState<AuditEvent[]>([])
const [agentReport, setAgentReport] = useState<AgentReport | null>(null)
const [configurationItems, setConfigurationItems] = useState<ConfigurationItem[]>([])
const [paymentProjects, setPaymentProjects] = useState<Project[]>([])
const [paymentRules, setPaymentRules] = useState<PaymentAutomationRule[]>([])
const [paymentSuggestions, setPaymentSuggestions] = useState<PaymentAutomationSuggestion[]>([])
const [configurationFocus, setConfigurationFocus] = useState<{
category: string
nonce: number
Expand All @@ -914,6 +926,7 @@ function App() {
agent: { key: "occurred_at", direction: "desc" },
audit: { key: "occurred_at", direction: "desc" },
configuration: { key: "category", direction: "asc" },
payments: { key: "priority", direction: "desc" },
})

const [minutes, setMinutes] = useState("60")
Expand Down Expand Up @@ -2035,6 +2048,112 @@ function App() {
}
}

async function loadPaymentAutomation() {
setBusy("paymentAutomation", true)
try {
const [rulesPayload, suggestionsPayload, projectsPayload] = await Promise.all([
requestJson<{ rules: PaymentAutomationRule[] }>("/dashboard/api/project-payment-rules"),
requestJson<{ suggestions: PaymentAutomationSuggestion[] }>(
"/dashboard/api/project-payment-suggestions?limit=100",
),
requestJson<ProjectsResponse>("/dashboard/api/projects?limit=100&status=Open"),
])
setPaymentRules(rulesPayload.rules || [])
setPaymentSuggestions(suggestionsPayload.suggestions || [])
setPaymentProjects(projectsPayload.projects || [])
} catch (error) {
showError(error, "Unable to load project payment automation")
} finally {
setBusy("paymentAutomation", false)
}
}

async function createPaymentRule(draft: PaymentAutomationRuleDraft) {
setBusy("paymentRuleCreate", true)
try {
const payload = await requestJson<{ rule: PaymentAutomationRule }>(
"/dashboard/api/project-payment-rules",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft),
},
)
setPaymentRules((current) => [payload.rule, ...current])
showToast("Created payment routing rule", "ok")
return true
} catch (error) {
showError(error, "Unable to create payment routing rule")
return false
} finally {
setBusy("paymentRuleCreate", false)
}
}

async function disablePaymentRule(rule: PaymentAutomationRule) {
setBusy(`paymentRule:${rule.id}`, true)
try {
const payload = await requestJson<{ rule: PaymentAutomationRule }>(
`/dashboard/api/project-payment-rules/${encodeURIComponent(rule.id)}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expected_version: rule.version }),
},
)
setPaymentRules((current) =>
current.map((currentRule) => (currentRule.id === rule.id ? payload.rule : currentRule)),
)
showToast("Disabled payment routing rule", "ok")
return true
} catch (error) {
showError(error, "Unable to disable payment routing rule")
return false
} finally {
setBusy(`paymentRule:${rule.id}`, false)
}
}

async function reviewPaymentSuggestion(
suggestion: PaymentAutomationSuggestion,
decision: "approve" | "reject",
) {
setBusy(`paymentSuggestion:${suggestion.id}`, true)
try {
const payload = await requestJson<{
status?: string
learning?: { status?: string }
}>(
`/dashboard/api/project-payment-suggestions/${encodeURIComponent(suggestion.id)}/${decision}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
},
)
setPaymentSuggestions((current) =>
current.filter((currentSuggestion) => currentSuggestion.id !== suggestion.id),
)
if (decision === "approve" && payload.learning?.status === "created") {
void loadPaymentAutomation()
}
showToast(
decision === "approve"
? payload.status === "approved_pending_recovery"
? "Approved payment suggestion; delivery recovery is queued"
: "Approved payment suggestion; routing is queued"
: "Rejected payment suggestion",
decision === "approve" && payload.status === "approved_pending_recovery" ? "warning" : "ok",
)
return true
} catch (error) {
showError(error, `Unable to ${decision} payment suggestion`)
return false
} finally {
setBusy(`paymentSuggestion:${suggestion.id}`, false)
}
}

async function updateConfigurationValue(key: string, value: string) {
setBusy(`configuration:${key}`, true)
try {
Expand Down Expand Up @@ -2412,6 +2531,7 @@ function App() {
if (view === "jobs") void loadJobs()
if (view === "agent") void loadAgentReport()
if (view === "audit") void loadAuditEvents()
if (view === "payments") void loadPaymentAutomation()
if (view === "configuration") {
void loadConfiguration()
void loadJobPostChannels({ includeAvailable: true })
Expand All @@ -2434,6 +2554,7 @@ function App() {
if (view === "jobs") void loadJobs()
if (view === "agent") void loadAgentReport()
if (view === "audit") void loadAuditEvents()
if (view === "payments") void loadPaymentAutomation()
if (view === "configuration") {
void loadConfiguration()
void loadJobPostChannels({ includeAvailable: true })
Expand Down Expand Up @@ -2698,6 +2819,7 @@ function App() {
["people", "People", Users],
["gigs", "Gigs", BriefcaseBusiness],
["projects", "Projects", FolderKanban],
["payments", "Payments", Bell],
["onboarding", "Onboarding", ClipboardList],
["newsletter", "Newsletter", Mail],
["jobs", "Background tasks", Activity],
Expand Down Expand Up @@ -2948,6 +3070,21 @@ function App() {
<AgentView report={agentReport} loading={loading} onRefresh={loadAgentReport} />
) : null}

{view === "payments" ? (
<PaymentAutomationView
projects={paymentProjects}
rules={paymentRules}
suggestions={paymentSuggestions}
loading={loading}
canWrite={can("configuration:write")}
onRefresh={loadPaymentAutomation}
onCreateRule={createPaymentRule}
onDisableRule={disablePaymentRule}
onApproveSuggestion={(suggestion) => reviewPaymentSuggestion(suggestion, "approve")}
onRejectSuggestion={(suggestion) => reviewPaymentSuggestion(suggestion, "reject")}
/>
) : null}

{view === "configuration" ? (
<ConfigurationView
items={configurationItems}
Expand Down
Loading