/** * ERPNext REST API client. * * Uses Frappe's token-based authentication: * Authorization: token api_key:api_secret * * All monetary values are stored in cents (integers) to avoid * floating-point rounding issues. */ // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- const ERPNEXT_URL = (process.env.ERPNEXT_URL || "http://erpnext:8000").replace( /\/$/, "", ); const API_KEY = process.env.ERPNEXT_API_KEY || ""; const API_SECRET = process.env.ERPNEXT_API_SECRET || ""; const ERPNEXT_SITE_NAME = process.env.ERPNEXT_SITE_NAME || process.env.ERPNEXT_HOST_HEADER || "performancewest.net"; const headers: Record = { "Content-Type": "application/json", Authorization: `token ${API_KEY}:${API_SECRET}`, "X-Frappe-Site-Name": ERPNEXT_SITE_NAME, }; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /** Shape returned by Frappe for a single document fetch. */ export interface FrappeDocResponse> { data: T; } /** Shape returned by Frappe for list fetches. */ export interface FrappeListResponse> { data: T[]; } /** Standard Frappe error body. */ export interface FrappeErrorResponse { exc_type?: string; exception?: string; _server_messages?: string; message?: string; } /** Thrown when the ERPNext API returns a non-2xx status. */ export class ERPNextError extends Error { status: number; body: FrappeErrorResponse; constructor(status: number, body: FrappeErrorResponse) { const msg = body.message || body.exception || body._server_messages || `ERPNext API error (HTTP ${status})`; super(msg); this.name = "ERPNextError"; this.status = status; this.body = body; } } // -- DocType-specific types ------------------------------------------------- export interface FormationOrder { name: string; order_number: string; customer: string; customer_name?: string; customer_email: string; customer_phone?: string; state_code: string; entity_type: "LLC" | "Corporation" | "S-Corp"; entity_name: string; entity_name_alt?: string; management_type?: "Member Managed" | "Manager Managed"; purpose?: string; principal_address?: string; mailing_address?: string; members_json?: string; include_ra_service?: 0 | 1; include_ein?: 0 | 1; include_operating_agreement?: 0 | 1; expedited?: 0 | 1; state_fee_cents?: number; service_fee_cents?: number; discount_code?: string; discount_cents?: number; total_cents?: number; automation_status?: "Pending" | "Running" | "Succeeded" | "Failed" | "Manual"; automation_error?: string; automation_attempts?: number; state_filing_number?: string; filed_at?: string; priority?: "Low" | "Normal" | "High" | "Urgent"; assigned_to?: string; admin_notes?: string; delivered_at?: string; docstatus?: 0 | 1 | 2; creation?: string; modified?: string; } export interface ComplianceCalendar { name: string; customer: string; customer_name?: string; formation_order?: string; title: string; description?: string; due_date: string; state_code?: string; category: "Annual Report" | "Franchise Tax" | "RA Renewal" | "PUC Filing" | "Other"; status: "Upcoming" | "Due Soon" | "Overdue" | "Completed"; completed_at?: string; notes?: string; creation?: string; modified?: string; } export interface SensitiveID { name: string; customer: string; customer_name?: string; id_type: "SSN" | "ITIN" | "EIN"; encrypted_value: string; notes?: string; created_by?: string; creation?: string; modified?: string; } export interface ReferralPartner { name: string; partner_name: string; email: string; active?: 0 | 1; discount_code?: string; discount_type?: "Percent" | "Flat"; discount_value?: number; commission_pct?: number; api_key_hash?: string; total_referrals?: number; total_commission_cents?: number; last_payout_date?: string; notes?: string; creation?: string; modified?: string; } export interface ComplianceService { name: string; service_name: string; slug: string; category: "Telecom" | "Employment" | "Privacy" | "TCPA" | "Corporate"; description?: string; price_cents: number; price_label?: string; turnaround?: string; requires_llm?: 0 | 1; template_name?: string; enabled?: 0 | 1; creation?: string; modified?: string; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- async function request( method: string, path: string, body?: Record, ): Promise { const url = `${ERPNEXT_URL}${path}`; const opts: RequestInit = { method, headers, }; if (body && (method === "POST" || method === "PUT")) { opts.body = JSON.stringify(body); } const res = await fetch(url, opts); if (!res.ok) { let errBody: FrappeErrorResponse = {}; try { errBody = (await res.json()) as FrappeErrorResponse; } catch { // response wasn't JSON } throw new ERPNextError(res.status, errBody); } // 204 No Content if (res.status === 204) { return {} as T; } return (await res.json()) as T; } function encodeFilters(filters: Record): string { return JSON.stringify( Object.entries(filters).map(([key, val]) => { if (Array.isArray(val)) { // Already in Frappe filter format: [field, operator, value] return val; } return [key, "=", val]; }), ); } // --------------------------------------------------------------------------- // Generic CRUD // --------------------------------------------------------------------------- /** * Fetch a single document or a list of documents. * * @param doctype - ERPNext DocType name * @param name - Document name (omit for list) * @param filters - Frappe-style filters (for list queries) * @param fields - Fields to return (default: all) * @param limit - Max results (default: 20) * @param orderBy - Order by clause (e.g. "creation desc") */ export async function getResource>( doctype: string, name?: string, filters?: Record, fields?: string[], limit?: number, orderBy?: string, ): Promise { if (name) { const res = await request>( "GET", `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, ); return res.data; } const params = new URLSearchParams(); if (filters && Object.keys(filters).length > 0) { params.set("filters", encodeFilters(filters)); } if (fields && fields.length > 0) { params.set("fields", JSON.stringify(fields)); } params.set("limit_page_length", String(limit ?? 20)); if (orderBy) { params.set("order_by", orderBy); } const qs = params.toString(); const res = await request>( "GET", `/api/resource/${encodeURIComponent(doctype)}${qs ? `?${qs}` : ""}`, ); return res.data; } /** * Create a new document. */ export async function createResource>( doctype: string, data: Record, ): Promise { const res = await request>( "POST", `/api/resource/${encodeURIComponent(doctype)}`, data, ); return res.data; } /** * Update an existing document. */ export async function updateResource>( doctype: string, name: string, data: Record, ): Promise { const res = await request>( "PUT", `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, data, ); return res.data; } /** * Delete a document. */ export async function deleteResource( doctype: string, name: string, ): Promise { await request<{ message: string }>( "DELETE", `/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`, ); } /** * Search / link field query — uses frappe.client.get_list under the hood. */ export async function searchLink>( doctype: string, query: string, filters?: Record, fields?: string[], limit?: number, ): Promise { const params = new URLSearchParams(); params.set("doctype", doctype); params.set("txt", query); if (filters) { params.set("filters", JSON.stringify(filters)); } if (fields) { params.set("fields", JSON.stringify(fields)); } params.set("limit_page_length", String(limit ?? 20)); const res = await request<{ results: T[] }>( "GET", `/api/method/frappe.client.get_list?${params.toString()}`, ); return res.results ?? (res as any).message ?? []; } /** * Call an arbitrary Frappe whitelisted method. */ export async function callMethod( method: string, args?: Record, ): Promise { const res = await request<{ message: T }>( "POST", `/api/method/${method}`, args, ); return res.message; } /** * Submit a submittable document (sets docstatus = 1). */ export async function submitDocument>( doctype: string, name: string, ): Promise { return callMethod("frappe.client.submit", { doc: { doctype, name } }); } /** * Cancel a submitted document (sets docstatus = 2). */ export async function cancelDocument>( doctype: string, name: string, ): Promise { return callMethod("frappe.client.cancel", { doctype, name }); } // --------------------------------------------------------------------------- // Domain-specific helpers // --------------------------------------------------------------------------- /** * Create a Lead from a website form submission. */ export async function createLead(data: { name: string; email: string; company?: string; source?: string; notes?: string; phone?: string; }) { return createResource>("Lead", { lead_name: data.name, email_id: data.email, company_name: data.company, source: data.source || "Website", notes: data.notes, phone: data.phone, }); } /** * Create a Sales Order for a compliance service. */ export async function createServiceOrder(data: { customer: string; service_slug: string; state_code?: string; discount_code?: string; notes?: string; }) { // Look up the service to get pricing const service = (await getResource( "Compliance Service", data.service_slug, )) as ComplianceService; let totalCents = service.price_cents; // Apply discount if provided let discountCents = 0; if (data.discount_code) { try { const partners = (await getResource( "Referral Partner", undefined, { discount_code: data.discount_code, active: 1 }, ["discount_type", "discount_value"], 1, )) as ReferralPartner[]; if (partners.length > 0) { const partner = partners[0]; if (partner.discount_type === "Percent") { discountCents = Math.round( totalCents * ((partner.discount_value || 0) / 100), ); } else { discountCents = partner.discount_value || 0; } } } catch { // Discount code not found — proceed without discount } } return createResource("Sales Order", { customer: data.customer, custom_service_slug: data.service_slug, custom_state_code: data.state_code, custom_discount_code: data.discount_code, custom_discount_cents: discountCents, custom_total_cents: totalCents - discountCents, custom_notes: data.notes, items: [ { item_code: service.slug, item_name: service.service_name, qty: 1, rate: (totalCents - discountCents) / 100, }, ], }); } /** * Create a Formation Order (business entity formation). */ export async function createFormationOrder(data: { order_number: string; customer: string; customer_email: string; customer_phone?: string; state_code: string; entity_type: "LLC" | "Corporation" | "S-Corp"; entity_name: string; entity_name_alt?: string; management_type?: "Member Managed" | "Manager Managed"; purpose?: string; principal_address?: string; mailing_address?: string; members?: Array<{ name: string; title?: string; address?: string; ownership_pct?: number; }>; include_ra_service?: boolean; include_ein?: boolean; include_operating_agreement?: boolean; expedited?: boolean; state_fee_cents: number; service_fee_cents: number; discount_code?: string; discount_cents?: number; total_cents: number; priority?: "Low" | "Normal" | "High" | "Urgent"; }) { return createResource("Formation Order", { order_number: data.order_number, customer: data.customer, customer_email: data.customer_email, customer_phone: data.customer_phone, state_code: data.state_code, entity_type: data.entity_type, entity_name: data.entity_name, entity_name_alt: data.entity_name_alt, management_type: data.management_type, purpose: data.purpose || "Any lawful business activity", principal_address: data.principal_address, mailing_address: data.mailing_address, members_json: data.members ? JSON.stringify(data.members) : undefined, include_ra_service: data.include_ra_service ? 1 : 0, include_ein: data.include_ein ? 1 : 0, include_operating_agreement: data.include_operating_agreement ? 1 : 0, expedited: data.expedited ? 1 : 0, state_fee_cents: data.state_fee_cents, service_fee_cents: data.service_fee_cents, discount_code: data.discount_code, discount_cents: data.discount_cents || 0, total_cents: data.total_cents, priority: data.priority || "Normal", automation_status: "Pending", }); } /** * Create an Issue (support ticket). */ export async function createIssue(data: { subject: string; description: string; customer?: string; issue_type?: string; priority?: string; }) { return createResource("Issue", { subject: data.subject, description: data.description, customer: data.customer, issue_type: data.issue_type, priority: data.priority || "Medium", }); } /** * Create a Compliance Calendar entry. */ export async function createComplianceEntry(data: { customer: string; formation_order?: string; title: string; description?: string; due_date: string; state_code?: string; category: "Annual Report" | "Franchise Tax" | "RA Renewal" | "PUC Filing" | "Other"; }) { return createResource("Compliance Calendar", { customer: data.customer, formation_order: data.formation_order, title: data.title, description: data.description, due_date: data.due_date, state_code: data.state_code, category: data.category, status: "Upcoming", }); } /** * Mark a Compliance Calendar entry as completed. */ export async function completeComplianceEntry(name: string) { return updateResource("Compliance Calendar", name, { status: "Completed", completed_at: new Date().toISOString(), }); } /** * Store a sensitive ID (SSN/ITIN/EIN) encrypted in ERPNext. */ export async function storeSensitiveID(data: { customer: string; id_type: "SSN" | "ITIN" | "EIN"; value: string; notes?: string; }) { return createResource("Sensitive ID", { customer: data.customer, id_type: data.id_type, encrypted_value: data.value, notes: data.notes, }); } /** * Look up a referral partner by discount code. */ export async function getPartnerByDiscountCode( code: string, ): Promise { const results = (await getResource( "Referral Partner", undefined, { discount_code: code, active: 1 }, undefined, 1, )) as ReferralPartner[]; return results.length > 0 ? results[0] : null; } /** * Increment referral count and commission for a partner. */ export async function recordReferralCommission( partnerName: string, commissionCents: number, ) { const partner = (await getResource( "Referral Partner", partnerName, )) as ReferralPartner; return updateResource("Referral Partner", partnerName, { total_referrals: (partner.total_referrals || 0) + 1, total_commission_cents: (partner.total_commission_cents || 0) + commissionCents, }); } /** * Get all compliance services, optionally filtered by category. */ export async function getComplianceServices( category?: string, ): Promise { const filters: Record = { enabled: 1 }; if (category) { filters.category = category; } return (await getResource( "Compliance Service", undefined, filters, undefined, 100, "service_name asc", )) as ComplianceService[]; } /** * Get upcoming compliance deadlines for a customer. */ export async function getUpcomingDeadlines( customer: string, limit = 20, ): Promise { return (await getResource( "Compliance Calendar", undefined, { customer, status: ["in", ["Upcoming", "Due Soon", "Overdue"]] }, undefined, limit, "due_date asc", )) as ComplianceCalendar[]; } /** * Update the automation status of a Formation Order. */ export async function updateFormationAutomation( orderName: string, status: "Pending" | "Running" | "Succeeded" | "Failed" | "Manual", extra?: { error?: string; state_filing_number?: string; filed_at?: string; }, ) { const update: Record = { automation_status: status }; if (status === "Failed" && extra?.error) { update.automation_error = extra.error; } if (extra?.state_filing_number) { update.state_filing_number = extra.state_filing_number; } if (extra?.filed_at) { update.filed_at = extra.filed_at; } // Increment attempts if running if (status === "Running") { const order = (await getResource( "Formation Order", orderName, )) as FormationOrder; update.automation_attempts = (order.automation_attempts || 0) + 1; } return updateResource("Formation Order", orderName, update); } // --------------------------------------------------------------------------- // Billing — ERPNext handles all invoicing and payment processing // --------------------------------------------------------------------------- /** * Create a Sales Invoice in ERPNext and get the payment link. * ERPNext creates a Payment Request that routes to Adyen or SHKeeper. * * Returns the payment URL that the customer can use to pay. */ export async function createInvoiceWithPaymentLink(data: { customer: string; items: Array<{ item_code: string; qty: number; rate: number; description?: string }>; discount_amount?: number; discount_code?: string; due_date?: string; }): Promise<{ invoice_name: string; payment_url: string }> { // Create Sales Invoice const invoice = await createResource>("Sales Invoice", { customer: data.customer, due_date: data.due_date || new Date(Date.now() + 7 * 86400000).toISOString().split("T")[0], items: data.items.map((item) => ({ item_code: item.item_code, qty: item.qty, rate: item.rate / 100, // Convert cents to dollars for ERPNext description: item.description || "", })), discount_amount: data.discount_amount ? data.discount_amount / 100 : 0, additional_discount_percentage: 0, remarks: data.discount_code ? `Discount code: ${data.discount_code}` : "", }); const invoiceName = (invoice as any).name || ""; // Submit the invoice to make it payable await callMethod("frappe.client.submit", { doc: JSON.stringify({ doctype: "Sales Invoice", name: invoiceName }) }); // Get payment request / link const paymentRequest = await callMethod("erpnext.accounts.doctype.payment_request.payment_request.make_payment_request", { dt: "Sales Invoice", dn: invoiceName, submit_doc: 1, return_doc: 1, }); const paymentUrl = (paymentRequest as any)?.payment_url || `${process.env.ERPNEXT_URL}/api/method/frappe.integrations.api.get_payment_url?dt=Sales Invoice&dn=${invoiceName}`; return { invoice_name: invoiceName, payment_url: paymentUrl }; } /** * Check if a Sales Invoice is paid. */ export async function isInvoicePaid(invoiceName: string): Promise { const invoice = await getResource>("Sales Invoice", invoiceName); return (invoice as any)?.status === "Paid" || (invoice as any)?.outstanding_amount === 0; } /** * Create a Credit Note (refund) against a Sales Invoice. * ERPNext will process the refund via the original payment gateway (Stripe). */ export async function createCreditNote(data: { against_invoice: string; items: Array<{ item_code: string; qty: number; rate: number }>; reason: string; }): Promise { const creditNote = await createResource>("Sales Invoice", { is_return: 1, return_against: data.against_invoice, items: data.items.map((item) => ({ item_code: item.item_code, qty: -item.qty, rate: item.rate / 100, })), remarks: `Refund reason: ${data.reason}`, }); const creditNoteName = (creditNote as any).name || ""; // Submit the credit note await callMethod("frappe.client.submit", { doc: JSON.stringify({ doctype: "Sales Invoice", name: creditNoteName }) }); return creditNoteName; } /** * Create a Subscription for recurring services (RA, annual reports). */ export async function createSubscription(data: { customer: string; plan: string; start_date: string; }): Promise { const sub = await createResource>("Subscription", { party_type: "Customer", party: data.customer, plans: [{ plan: data.plan, qty: 1 }], start_date: data.start_date, }); return (sub as any).name || ""; } // --------------------------------------------------------------------------- // Invoice creation on payment // --------------------------------------------------------------------------- /** * Create a Sales Invoice from a Sales Order and record the payment against it. * * Flow: * 1. Make Sales Invoice from Sales Order (ERPNext standard method) * 2. Submit the invoice * 3. Create a Payment Entry (marks invoice as Paid, stores gateway reference) * * This is called by handlePaymentComplete so every paid order has a proper * accounting trail visible in the ERPNext portal under "My Invoices". * * Returns the invoice name, or null if ERPNext is unavailable (non-fatal). */ export async function createInvoiceFromSalesOrder(opts: { salesOrderName: string; paymentGateway: string; surchargePercent: number; paymentReference: string; // Stripe session ID, PayPal order ID, SHKeeper invoice ID paidAmountCents: number; externalOrderId: string; currency?: string; }): Promise { const { salesOrderName, paymentGateway, surchargePercent, paymentReference, paidAmountCents, externalOrderId, currency = "USD", } = opts; // Step 1: Make Sales Invoice from Sales Order let invoiceName: string; try { const siDoc = await callMethod>( "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice", { source_name: salesOrderName }, ); if (!siDoc || !siDoc.items) { throw new Error("make_sales_invoice returned empty doc"); } // Set custom fields on the invoice doc before saving siDoc.custom_payment_gateway = paymentGateway; siDoc.custom_surcharge_pct = surchargePercent; siDoc.custom_external_order_id = externalOrderId; siDoc.custom_stripe_payment_intent = paymentReference; // Save (insert) the draft invoice const saved = await request>>( "POST", "/api/resource/Sales%20Invoice", siDoc, ); invoiceName = saved.data?.name; if (!invoiceName) throw new Error("Invoice save returned no name"); } catch (err) { console.error("[erpnext] createInvoiceFromSalesOrder: make/save failed:", err); return null; } // Step 2: Submit the invoice (fetch fresh doc to avoid timestamp mismatch) try { const freshInv = await request>>( "GET", `/api/resource/Sales%20Invoice/${invoiceName}`, ); await callMethod("frappe.client.submit", { doc: JSON.stringify(freshInv.data), }); } catch (err) { console.warn(`[erpnext] Invoice submit failed for ${invoiceName} (non-fatal):`, err); } // Step 3: Create Payment Entry to mark invoice Paid try { const peDoc = await callMethod>( "erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry", { dt: "Sales Invoice", dn: invoiceName, party_amount: paidAmountCents / 100, bank_account: null, bank_amount: null, }, ); if (peDoc) { peDoc.reference_no = paymentReference; peDoc.reference_date = new Date().toISOString().split("T")[0]; peDoc.remarks = `${paymentGateway} payment — order ${externalOrderId}`; peDoc.paid_amount = paidAmountCents / 100; peDoc.received_amount = paidAmountCents / 100; // Save + submit Payment Entry const savedPE = await request>>( "POST", "/api/resource/Payment%20Entry", peDoc, ); const peName = savedPE.data?.name; if (peName) { try { const freshPE = await request>>( "GET", `/api/resource/Payment%20Entry/${peName}`, ); await callMethod("frappe.client.submit", { doc: JSON.stringify(freshPE.data), }); console.log(`[erpnext] Payment Entry ${peName} submitted for invoice ${invoiceName}`); } catch (submitErr) { console.warn(`[erpnext] Payment Entry submit failed (non-fatal):`, submitErr); } } } } catch (peErr) { console.warn(`[erpnext] Payment Entry creation failed for ${invoiceName} (non-fatal):`, peErr); } return invoiceName; } // --------------------------------------------------------------------------- // ERPNext Portal / Website User helpers // --------------------------------------------------------------------------- /** * Find or create an ERPNext Website User (portal login account) for a customer. * * ERPNext Website Users are standard Frappe users with the "Website User" role. * They can log into portal.performancewest.net and see their own Sales Orders, * Sales Invoices, and Issues. * * Returns `{ user, created }` — the caller uses `created === true` to decide * whether to surface a set-password UI on the success page and to include a * magic-link fallback in the delivery email (new customers only). */ export async function ensureWebsiteUser( email: string, fullName: string, ): Promise<{ user: string; created: boolean }> { // Check if a Frappe User with this email already exists try { await request>>( "GET", `/api/resource/User/${encodeURIComponent(email)}`, ); // User already exists — nothing to do return { user: email, created: false }; } catch (err) { if (err instanceof ERPNextError && err.status !== 404) { throw err; } // 404 = does not exist, proceed to create } // Split full name const parts = fullName.trim().split(/\s+/); const firstName = parts[0] || fullName; const lastName = parts.slice(1).join(" ") || ""; await createResource("User", { email, first_name: firstName, last_name: lastName, enabled: 1, send_welcome_email: 0, user_type: "Website User", redirect_url: "/me", roles: [{ role: "Customer" }], }); return { user: email, created: true }; } /** * Set (or update) the ERPNext portal password for a user. * * Uses frappe.client.set_value on the User doctype's `new_password` field, * which triggers Frappe's built-in password hashing and validation. */ export async function setWebsiteUserPassword( email: string, newPassword: string, ): Promise { await callMethod("frappe.client.set_value", { doctype: "User", name: email, fieldname: "new_password", value: newPassword, }); } /** * Link a Frappe User to a Customer record (portal_user_name field). * This is required for the ERPNext portal to show the correct customer's data. */ export async function linkUserToCustomer( customerName: string, email: string, ): Promise { try { await updateResource("Customer", customerName, { portal_user_name: email, }); } catch (err) { // Non-fatal: portal still works via customer match on email_id console.warn("[erpnext] linkUserToCustomer warning:", err); } }