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.
1072 lines
30 KiB
TypeScript
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);
|
|
}
|
|
}
|