new-site/api/src/erpnext-client.ts
justin 9c87759501 auth: make ERPNext the single source of truth for customer passwords
Customer portal login previously checked a bcrypt customers.password_hash
in Postgres, while portal.performancewest.net validated against ERPNext —
two stores that drifted (the Paul Wilson lockout). Consolidate on ERPNext:

- erpnext-client: add verifyWebsiteUserPassword() — delegates the credential
  check to Frappe /api/method/login (Host header = site name; 200=ok,401=bad).
- portal-auth /login: verify against ERPNext, then mint the pw_customer cookie.
- portal-auth /register: create+set the ERPNext password (authority) and upsert
  a password-less customers profile row; takeover guard still honors any legacy
  PG password until the column is dropped.
- portal-auth /reset-password + /forgot-password: write the new password to
  ERPNext; forgot-password now also works for ERPNext-only users (creates the
  PG profile row on demand).
- Legacy customers with only a PG bcrypt password reset via forgot-password.
- checkout: refresh the stale comment (customers row is now a profile, no pw).

Build + typecheck green.
2026-06-17 10:09:32 -05:00

1072 lines
30 KiB
TypeScript

/**
* 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<string, string> = {
"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<T = Record<string, any>> {
data: T;
}
/** Shape returned by Frappe for list fetches. */
export interface FrappeListResponse<T = Record<string, any>> {
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<T>(
method: string,
path: string,
body?: Record<string, any>,
): Promise<T> {
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, any>): 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<T = Record<string, any>>(
doctype: string,
name?: string,
filters?: Record<string, any>,
fields?: string[],
limit?: number,
orderBy?: string,
): Promise<T | T[]> {
if (name) {
const res = await request<FrappeDocResponse<T>>(
"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<FrappeListResponse<T>>(
"GET",
`/api/resource/${encodeURIComponent(doctype)}${qs ? `?${qs}` : ""}`,
);
return res.data;
}
/**
* Create a new document.
*/
export async function createResource<T = Record<string, any>>(
doctype: string,
data: Record<string, any>,
): Promise<T> {
const res = await request<FrappeDocResponse<T>>(
"POST",
`/api/resource/${encodeURIComponent(doctype)}`,
data,
);
return res.data;
}
/**
* Update an existing document.
*/
export async function updateResource<T = Record<string, any>>(
doctype: string,
name: string,
data: Record<string, any>,
): Promise<T> {
const res = await request<FrappeDocResponse<T>>(
"PUT",
`/api/resource/${encodeURIComponent(doctype)}/${encodeURIComponent(name)}`,
data,
);
return res.data;
}
/**
* Delete a document.
*/
export async function deleteResource(
doctype: string,
name: string,
): Promise<void> {
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<T = Record<string, any>>(
doctype: string,
query: string,
filters?: Record<string, any>,
fields?: string[],
limit?: number,
): Promise<T[]> {
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<T = any>(
method: string,
args?: Record<string, any>,
): Promise<T> {
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<T = Record<string, any>>(
doctype: string,
name: string,
): Promise<T> {
return callMethod<T>("frappe.client.submit", { doc: { doctype, name } });
}
/**
* Cancel a submitted document (sets docstatus = 2).
*/
export async function cancelDocument<T = Record<string, any>>(
doctype: string,
name: string,
): Promise<T> {
return callMethod<T>("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<Record<string, any>>("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<ComplianceService>(
"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<ReferralPartner>(
"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<FormationOrder>("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<ComplianceCalendar>("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<ComplianceCalendar>("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<SensitiveID>("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<ReferralPartner | null> {
const results = (await getResource<ReferralPartner>(
"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<ReferralPartner>(
"Referral Partner",
partnerName,
)) as ReferralPartner;
return updateResource<ReferralPartner>("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<ComplianceService[]> {
const filters: Record<string, any> = { enabled: 1 };
if (category) {
filters.category = category;
}
return (await getResource<ComplianceService>(
"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<ComplianceCalendar[]> {
return (await getResource<ComplianceCalendar>(
"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<string, any> = { 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<FormationOrder>(
"Formation Order",
orderName,
)) as FormationOrder;
update.automation_attempts = (order.automation_attempts || 0) + 1;
}
return updateResource<FormationOrder>("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<Record<string, unknown>>("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<boolean> {
const invoice = await getResource<Record<string, unknown>>("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<string> {
const creditNote = await createResource<Record<string, unknown>>("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<string> {
const sub = await createResource<Record<string, unknown>>("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<string | null> {
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<Record<string, any>>(
"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<FrappeDocResponse<Record<string, any>>>(
"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<FrappeDocResponse<Record<string, any>>>(
"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<Record<string, any>>(
"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<FrappeDocResponse<Record<string, any>>>(
"POST",
"/api/resource/Payment%20Entry",
peDoc,
);
const peName = savedPE.data?.name;
if (peName) {
try {
const freshPE = await request<FrappeDocResponse<Record<string, any>>>(
"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<FrappeDocResponse<Record<string, any>>>(
"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<void> {
await callMethod("frappe.client.set_value", {
doctype: "User",
name: email,
fieldname: "new_password",
value: newPassword,
});
}
/**
* Verify a customer's password against ERPNext — the single source of truth for
* customer credentials. The API portal does NOT keep its own password hash; it
* delegates the check here and (on success) mints its own session cookie.
*
* Uses Frappe's form login endpoint (`/api/method/login`, usr/pwd). That
* endpoint resolves the site by the Host header (NOT the X-Frappe-Site-Name
* token header), so we must send Host explicitly or Frappe 404s with
* "<site> does not exist". A 200 means the password is correct; 401 means it
* is not. Network/other errors throw so the caller can fail closed.
*
* NB: this is a plain credential check — we discard any session cookie ERPNext
* returns; the API issues its own `pw_customer` cookie.
*/
export async function verifyWebsiteUserPassword(
email: string,
password: string,
): Promise<boolean> {
const res = await fetch(`${ERPNEXT_URL}/api/method/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Frappe resolves the site for this endpoint from Host, not the token
// site header. Send both so it works regardless of routing.
Host: ERPNEXT_SITE_NAME,
"X-Frappe-Site-Name": ERPNEXT_SITE_NAME,
},
body: JSON.stringify({ usr: email, pwd: password }),
});
if (res.status === 200) return true;
if (res.status === 401) return false;
// 4xx/5xx other than auth failure (e.g. user disabled, site error) — surface
// as an error so the route returns a 500 rather than a misleading "bad
// password". Read the body for diagnostics.
const body = await res.text().catch(() => "");
throw new ERPNextError(res.status, {
message: `ERPNext login check failed (status ${res.status})`,
exception: body.slice(0, 500),
} as FrappeErrorResponse);
}
/**
* 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<void> {
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);
}
}