compliance_orders has payment_status, not status. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2055 lines
85 KiB
TypeScript
2055 lines
85 KiB
TypeScript
/**
|
|
* Checkout Routes — Stripe Checkout Sessions
|
|
*
|
|
* POST /api/v1/checkout/create-session
|
|
* 1. Validate input
|
|
* 2. CRTC orders: verify identity status
|
|
* 3. Fetch order from PG, build line items
|
|
* 4. Calculate surcharge server-side (never trust client)
|
|
* 5. Find or create ERPNext Customer
|
|
* 6. Duplicate-check (idempotency)
|
|
* 7. Create Stripe Checkout Session
|
|
* 8. Record session_id on PG order row
|
|
* 9. Return { checkout_url, session_id, total_cents, surcharge_cents }
|
|
*
|
|
* GET /api/v1/checkout/session/:session_id
|
|
* Polls Stripe for session status.
|
|
* On "paid": updates PG order → advances ERPNext Sales Order workflow.
|
|
*
|
|
* Payment confirmation also arrives via Stripe webhook:
|
|
* POST /api/v1/webhooks/stripe (in webhooks.ts)
|
|
*/
|
|
|
|
import { Router } from "express";
|
|
import { z } from "zod";
|
|
import Stripe from "stripe";
|
|
import { pool } from "../db.js";
|
|
import {
|
|
getResource,
|
|
createResource,
|
|
callMethod,
|
|
ERPNextError,
|
|
ensureWebsiteUser,
|
|
linkUserToCustomer,
|
|
createInvoiceFromSalesOrder,
|
|
updateResource,
|
|
} from "../erpnext-client.js";
|
|
import { sendOrderConfirmationEmail } from "../email";
|
|
|
|
const router = Router();
|
|
|
|
// ─── Stripe client ────────────────────────────────────────────────────────────
|
|
|
|
const STRIPE_SECRET_KEY =
|
|
(process.env.NODE_ENV !== "production" && process.env.STRIPE_TEST_SECRET_KEY?.trim()) ||
|
|
process.env.STRIPE_SECRET_KEY ||
|
|
"";
|
|
const STRIPE_API_VERSION: Stripe.LatestApiVersion = "2026-03-25.dahlia";
|
|
const stripe = STRIPE_SECRET_KEY
|
|
? new Stripe(STRIPE_SECRET_KEY, { apiVersion: STRIPE_API_VERSION })
|
|
: null;
|
|
|
|
const DOMAIN = process.env.DOMAIN
|
|
? `https://${process.env.DOMAIN}`
|
|
: "http://localhost:4321";
|
|
|
|
// ─── Surcharge rates (applied to service fees only, never gov/filing fees) ────
|
|
|
|
const GATEWAY_SURCHARGES: Record<string, number> = {
|
|
card: 3.0, // Stripe 2.9% + 30c; we charge flat 3%
|
|
ach: 0.0, // ACH (Stripe) — 0.8% capped $5; too small to itemize
|
|
paypal: 3.0, // PayPal direct — 2.99% + 49c; we charge 3%
|
|
klarna: 6.0, // Stripe Klarna 5.99% + 30c; we charge 6%
|
|
crypto: 0.0, // SHKeeper — 0%
|
|
};
|
|
|
|
// Human-readable gateway labels for invoice line items
|
|
const GATEWAY_LABELS: Record<string, string> = {
|
|
card: "Card",
|
|
ach: "ACH",
|
|
paypal: "PayPal",
|
|
klarna: "Klarna",
|
|
crypto: "Crypto",
|
|
};
|
|
|
|
// ─── Validation schema ────────────────────────────────────────────────────────
|
|
|
|
const CreateSessionSchema = z.object({
|
|
order_id: z.string().min(1),
|
|
order_type: z.enum(["canada_crtc", "formation", "bundle", "compliance", "compliance_batch", "fcc_carrier_registration"]),
|
|
payment_method: z.enum(["card", "ach", "paypal", "klarna", "crypto"]),
|
|
});
|
|
|
|
// ─── Schema migration (run once at startup) ───────────────────────────────────
|
|
|
|
let schemaMigrated = false;
|
|
|
|
async function ensureColumns(): Promise<void> {
|
|
if (schemaMigrated) return;
|
|
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
|
|
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS erpnext_sales_order TEXT`);
|
|
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
|
|
await pool.query(`ALTER TABLE canada_crtc_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
|
|
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
|
|
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
|
|
await pool.query(`ALTER TABLE formation_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
|
|
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS stripe_session_id TEXT`);
|
|
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_method TEXT`);
|
|
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_pct NUMERIC`);
|
|
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS surcharge_cents INTEGER`);
|
|
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS payment_status TEXT DEFAULT 'pending_payment'`);
|
|
await pool.query(`ALTER TABLE bundle_orders ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ`);
|
|
// compliance_orders columns are created by migration 044 — just ensure extras
|
|
try {
|
|
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS paypal_order_id TEXT`);
|
|
await pool.query(`ALTER TABLE compliance_orders ADD COLUMN IF NOT EXISTS crypto_details JSONB`);
|
|
} catch { /* table may not exist yet */ }
|
|
schemaMigrated = true;
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function toDollars(cents: number): number {
|
|
return Math.round(cents) / 100;
|
|
}
|
|
|
|
/**
|
|
* Find or create ERPNext Customer.
|
|
*
|
|
* Returns the customer's Frappe link key plus a `portal_user_created` flag
|
|
* — true when this request was the one to create the ERPNext Website User
|
|
* (i.e. the customer is new and needs a set-password step on the success
|
|
* page). The flag is persisted back to the PG order row by the caller so
|
|
* the success page and the delivery worker can surface the onboarding
|
|
* password/magic-link UI.
|
|
*/
|
|
async function findOrCreateCustomer(
|
|
email: string,
|
|
fullName: string,
|
|
): Promise<{ customerName: string; portalUserCreated: boolean }> {
|
|
// ERPNext Customer standard field is email_id
|
|
const existing = (await getResource(
|
|
"Customer",
|
|
undefined,
|
|
{ email_id: email },
|
|
["name"],
|
|
1,
|
|
)) as Array<{ name: string }>;
|
|
|
|
let customerName: string;
|
|
|
|
if (existing.length > 0) {
|
|
customerName = existing[0].name;
|
|
} else {
|
|
const created = (await createResource("Customer", {
|
|
customer_name: fullName,
|
|
customer_type: "Individual",
|
|
customer_group: "Individual",
|
|
territory: "All Territories",
|
|
email_id: email,
|
|
})) as { name: string };
|
|
customerName = created.name;
|
|
}
|
|
|
|
// Ensure matching ERPNext Website User exists (non-fatal if it fails).
|
|
// `created` distinguishes first-touch customers from returning ones.
|
|
let portalUserCreated = false;
|
|
try {
|
|
const { created } = await ensureWebsiteUser(email, fullName);
|
|
portalUserCreated = created;
|
|
await linkUserToCustomer(customerName, email);
|
|
} catch (err) {
|
|
console.warn("[checkout] ensureWebsiteUser warning (non-fatal):", err);
|
|
}
|
|
|
|
return { customerName, portalUserCreated };
|
|
}
|
|
|
|
/**
|
|
* Fetch order from PG and build Stripe line items + surcharge basis.
|
|
* Returns null if order not found or not in pending_payment state.
|
|
*/
|
|
// Service slugs whose payment unlocks the classified CDR traffic study
|
|
// for the reporting year (paywall — see plan file § I.).
|
|
const CDR_STUDY_GRANTING_SLUGS = new Set([
|
|
"fcc-499a",
|
|
"fcc-499a-499q",
|
|
"fcc-full-compliance",
|
|
"cdr-analysis",
|
|
]);
|
|
|
|
async function grantCDRStudyAccess(
|
|
order: Record<string, unknown>,
|
|
order_id: string,
|
|
): Promise<void> {
|
|
const serviceSlug = (order.service_slug as string) || "";
|
|
if (!serviceSlug || !CDR_STUDY_GRANTING_SLUGS.has(serviceSlug)) return;
|
|
|
|
const telecomEntityId = order.telecom_entity_id as number | null;
|
|
if (!telecomEntityId) return;
|
|
|
|
// Find the cdr_ingestion_profile for this entity (may not exist if the
|
|
// customer hasn't enabled CDR ingestion yet — grants can predate profiles).
|
|
const profileRows = await pool.query(
|
|
`SELECT id FROM cdr_ingestion_profiles WHERE telecom_entity_id = $1`,
|
|
[telecomEntityId],
|
|
);
|
|
if (profileRows.rows.length === 0) {
|
|
console.log(
|
|
`[checkout] CDR paywall: no profile yet for entity ${telecomEntityId} — ` +
|
|
`grant will be issued when the profile is created (paywall lookup checks intents).`,
|
|
);
|
|
// Stash the intent on compliance_orders so profile creation can back-fill.
|
|
await pool.query(
|
|
`UPDATE compliance_orders
|
|
SET intake_data = jsonb_set(
|
|
COALESCE(intake_data, '{}'::jsonb),
|
|
'{cdr_grant_pending}', 'true'::jsonb
|
|
)
|
|
WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
return;
|
|
}
|
|
|
|
const profileId = profileRows.rows[0].id as number;
|
|
|
|
// Reporting year — prefer intake_data.reporting_year (customer may
|
|
// explicitly file for a prior year), fall back to the order's year.
|
|
const intake = (order.intake_data as Record<string, unknown>) || {};
|
|
const reportingYear =
|
|
Number(intake.reporting_year) ||
|
|
new Date(order.created_at as string).getUTCFullYear();
|
|
|
|
await pool.query(
|
|
`INSERT INTO cdr_study_access_grants (profile_id, reporting_year, granted_by_order)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (profile_id, reporting_year, granted_by_order) DO NOTHING`,
|
|
[profileId, reportingYear, order_id],
|
|
);
|
|
console.log(
|
|
`[checkout] CDR grant issued: profile=${profileId} year=${reportingYear} order=${order_id}`,
|
|
);
|
|
}
|
|
|
|
async function fetchOrderData(
|
|
order_id: string,
|
|
order_type: string,
|
|
): Promise<{
|
|
order: Record<string, unknown>;
|
|
stripeLineItems: Stripe.Checkout.SessionCreateParams.LineItem[];
|
|
service_cents: number;
|
|
base_cents: number;
|
|
discount_cents?: number;
|
|
customer_email: string;
|
|
customer_name: string;
|
|
} | null> {
|
|
|
|
// ── Canada CRTC ─────────────────────────────────────────────────────────
|
|
if (order_type === "fcc_carrier_registration") {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM fcc_carrier_registrations WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
if (!rows.length) return null;
|
|
const order = rows[0] as Record<string, unknown>;
|
|
if (order.payment_status !== "pending_payment") return null;
|
|
|
|
const baseCents = (order.service_fee_cents as number) || 129900;
|
|
const formCents = ((order.formation_fee_cents as number) || 0) + ((order.state_fee_cents as number) || 0);
|
|
const addonCents = (order.addon_fee_cents as number) || 0;
|
|
const pucCents = (order.puc_fee_cents as number) || 0;
|
|
const totalCents = baseCents + formCents + addonCents + pucCents;
|
|
|
|
const lineItems: Array<{price_data: {currency: "usd"; product_data: {name: string}; unit_amount: number}; quantity: number}> = [
|
|
{ price_data: { currency: "usd", product_data: { name: "FCC Carrier / ISP Registration" }, unit_amount: baseCents }, quantity: 1 },
|
|
];
|
|
if (formCents > 0) {
|
|
lineItems.push({ price_data: { currency: "usd", product_data: { name: `Business Formation (${order.formation_state || "?"} ${((order.entity_type as string) || "LLC").toUpperCase()})` }, unit_amount: formCents }, quantity: 1 });
|
|
}
|
|
if ((order.include_stir_shaken as boolean)) {
|
|
lineItems.push({ price_data: { currency: "usd", product_data: { name: "STIR/SHAKEN Implementation" }, unit_amount: 49900 }, quantity: 1 });
|
|
}
|
|
if ((order.include_ocn as boolean)) {
|
|
lineItems.push({ price_data: { currency: "usd", product_data: { name: "NECA OCN Registration" }, unit_amount: 265000 }, quantity: 1 });
|
|
}
|
|
if (pucCents > 0) {
|
|
const stateCount = ((order.state_puc_states as string[]) || []).length;
|
|
lineItems.push({ price_data: { currency: "usd", product_data: { name: `State PUC Registration (${stateCount} state${stateCount !== 1 ? "s" : ""})` }, unit_amount: pucCents }, quantity: 1 });
|
|
}
|
|
|
|
return {
|
|
order,
|
|
stripeLineItems: lineItems as Stripe.Checkout.SessionCreateParams.LineItem[],
|
|
service_cents: totalCents,
|
|
base_cents: totalCents,
|
|
customer_email: (order.customer_email as string) || "",
|
|
customer_name: (order.customer_name as string) || "",
|
|
};
|
|
}
|
|
|
|
if (order_type === "canada_crtc") {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM canada_crtc_orders WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
if (!rows.length) return null;
|
|
const order = rows[0] as Record<string, unknown>;
|
|
if (order.payment_status !== "pending_payment") return null;
|
|
|
|
const service_cents = (order.service_fee_cents as number) || 389900;
|
|
const gov_fee_cents = (order.government_fee_cents as number) || 35000;
|
|
const mailbox_cents = 12000;
|
|
const company_type = (order.company_type as string) || "numbered";
|
|
|
|
return {
|
|
order,
|
|
stripeLineItems: [
|
|
{
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: "Canada CRTC Telecom Carrier Package", description: `${order.incorporation_province || "BC"} incorporation, CRTC registration, CCTS, domain, email, DID, binder (${company_type})` },
|
|
unit_amount: service_cents,
|
|
},
|
|
quantity: 1,
|
|
},
|
|
{
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: "Provincial Filing Fees", description: `Non-refundable government filing fees — ${order.incorporation_province === "ON" ? "Ontario Business Registry" : "BC Corporate Online"}` },
|
|
unit_amount: gov_fee_cents,
|
|
},
|
|
quantity: 1,
|
|
},
|
|
{
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: "Vancouver Virtual Office", description: `Registered office — ${(order.mailbox_address as string) || "329 Howe St, Vancouver"} — 1 year` },
|
|
unit_amount: mailbox_cents,
|
|
},
|
|
quantity: 1,
|
|
},
|
|
],
|
|
service_cents,
|
|
base_cents: service_cents + gov_fee_cents + mailbox_cents,
|
|
customer_email: (order.customer_email as string) || "",
|
|
customer_name: (order.customer_name as string) || "Unknown",
|
|
};
|
|
}
|
|
|
|
// ── US Formation ─────────────────────────────────────────────────────────
|
|
if (order_type === "formation") {
|
|
const { rows } = await pool.query(
|
|
`SELECT * FROM formation_orders WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
if (!rows.length) return null;
|
|
const order = rows[0] as Record<string, unknown>;
|
|
if (order.payment_status !== "pending_payment") return null;
|
|
|
|
const svc = (order.service_fee_cents as number) || 17900;
|
|
const fees = (order.state_fee_cents as number) || 0;
|
|
const stateCode = (order.state_code as string) || "";
|
|
const entityType = (order.entity_type as string) || "LLC";
|
|
|
|
const items: Stripe.Checkout.SessionCreateParams.LineItem[] = [
|
|
{
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: `${entityType} Formation`, description: `${entityType} Formation — ${stateCode}` },
|
|
unit_amount: svc,
|
|
},
|
|
quantity: 1,
|
|
},
|
|
];
|
|
|
|
if (fees > 0) {
|
|
items.push({
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: "State Filing Fee", description: `${stateCode} state filing fee (non-refundable government fee)` },
|
|
unit_amount: fees,
|
|
},
|
|
quantity: 1,
|
|
});
|
|
}
|
|
|
|
return {
|
|
order,
|
|
stripeLineItems: items,
|
|
service_cents: svc,
|
|
base_cents: svc + fees,
|
|
customer_email: (order.customer_email as string) || "",
|
|
customer_name: (order.customer_name as string) || "Unknown",
|
|
};
|
|
}
|
|
|
|
// ── Bundle ────────────────────────────────────────────────────────────────
|
|
if (order_type === "bundle") {
|
|
const { rows } = await pool.query(
|
|
`SELECT bo.*, sb.name as bundle_name
|
|
FROM bundle_orders bo
|
|
JOIN service_bundles sb ON sb.slug = bo.bundle_slug
|
|
WHERE bo.order_number = $1`,
|
|
[order_id],
|
|
);
|
|
if (!rows.length) return null;
|
|
const order = rows[0] as Record<string, unknown>;
|
|
// Allow 'received' or 'pending_payment' — bundles start as 'received'
|
|
if (order.payment_status && order.payment_status !== "pending_payment" && order.payment_status !== "received") return null;
|
|
|
|
const total_cents = (order.final_total_cents as number) || 0;
|
|
const discount_cents = (order.discount_cents as number) || 0;
|
|
const bundle_name = (order.bundle_name as string) || (order.bundle_slug as string) || "Service Bundle";
|
|
|
|
const items: Stripe.Checkout.SessionCreateParams.LineItem[] = [
|
|
{
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: bundle_name, description: "20% bundle discount applied" },
|
|
unit_amount: total_cents + discount_cents,
|
|
},
|
|
quantity: 1,
|
|
},
|
|
];
|
|
|
|
if (discount_cents > 0) {
|
|
// Stripe Checkout doesn't support negative line items directly;
|
|
// we encode the discount as a coupon applied to the session instead.
|
|
}
|
|
|
|
return {
|
|
order,
|
|
stripeLineItems: items,
|
|
service_cents: total_cents,
|
|
base_cents: total_cents,
|
|
customer_email: (order.customer_email as string) || "",
|
|
customer_name: (order.customer_name as string) || "Unknown",
|
|
};
|
|
}
|
|
|
|
// ── Compliance service (CCPA audit, FLSA audit, etc.) ────────────────────
|
|
if (order_type === "compliance") {
|
|
let rows: Record<string, unknown>[] = [];
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT * FROM compliance_orders WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
rows = result.rows as Record<string, unknown>[];
|
|
} catch (err: any) {
|
|
if (err?.code === "42P01") {
|
|
console.warn("[checkout] compliance_orders table not found; compliance checkout is unavailable");
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
if (!rows.length) return null;
|
|
const order = rows[0] as Record<string, unknown>;
|
|
if (order.payment_status !== "pending_payment") return null;
|
|
|
|
// Gate on pre-payment validation — intake must have been verified by
|
|
// POST /api/v1/compliance-orders/:order_number/validate before Stripe
|
|
// Checkout can run. Prevents submissions that would fail at the
|
|
// handler for missing required fields.
|
|
if (order.intake_data_validated !== true) {
|
|
throw Object.assign(
|
|
new Error(
|
|
"Order intake has not been validated. POST /api/v1/compliance-orders/" +
|
|
order.order_number + "/validate first.",
|
|
),
|
|
{ status: 422, code: "INTAKE_NOT_VALIDATED" },
|
|
);
|
|
}
|
|
|
|
const svc = (order.service_fee_cents as number) || 0;
|
|
const govFee = (order.gov_fee_cents as number) || 0;
|
|
const service_name = (order.service_name as string) || "Compliance Service";
|
|
|
|
const lineItems: Array<{price_data: {currency: "usd"; product_data: {name: string}; unit_amount: number}; quantity: number}> = [
|
|
{
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: service_name },
|
|
unit_amount: svc,
|
|
},
|
|
quantity: 1,
|
|
},
|
|
];
|
|
if (govFee > 0) {
|
|
lineItems.push({
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: (order.gov_fee_label as string) || "Government filing fee" },
|
|
unit_amount: govFee,
|
|
},
|
|
quantity: 1,
|
|
});
|
|
}
|
|
|
|
return {
|
|
order,
|
|
stripeLineItems: lineItems,
|
|
service_cents: svc + govFee,
|
|
base_cents: svc + govFee,
|
|
customer_email: (order.customer_email as string) || "",
|
|
customer_name: (order.customer_name as string) || "Unknown",
|
|
};
|
|
}
|
|
|
|
if (order_type === "compliance_batch") {
|
|
// Batch checkout: order_id is the batch_id (CB-XXXXXXXX)
|
|
let rows: Record<string, unknown>[] = [];
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT * FROM compliance_orders WHERE batch_id = $1 AND payment_status = 'pending_payment' ORDER BY created_at`,
|
|
[order_id],
|
|
);
|
|
rows = result.rows as Record<string, unknown>[];
|
|
} catch (err: any) {
|
|
if (err?.code === "42P01") return null;
|
|
throw err;
|
|
}
|
|
if (!rows.length) return null;
|
|
|
|
// Build Stripe line items at FULL price — discount applied via Stripe coupon
|
|
const stripeLineItems: Array<{price_data: {currency: "usd"; product_data: {name: string}; unit_amount: number}; quantity: number}> = [];
|
|
for (const order of rows) {
|
|
const price = (order.service_fee_cents as number) || 0;
|
|
stripeLineItems.push({
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: (order.service_name as string) || "Compliance Service" },
|
|
unit_amount: price,
|
|
},
|
|
quantity: 1,
|
|
});
|
|
// Gov filing fees shown as separate passthrough line items
|
|
const govFee = (order.gov_fee_cents as number) || 0;
|
|
if (govFee > 0) {
|
|
stripeLineItems.push({
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: { name: (order.gov_fee_label as string) || "Government filing fee" },
|
|
unit_amount: govFee,
|
|
},
|
|
quantity: 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
const totalService = rows.reduce((sum, o) => sum + ((o.service_fee_cents as number) || 0), 0);
|
|
const totalGovFees = rows.reduce((sum, o) => sum + ((o.gov_fee_cents as number) || 0), 0);
|
|
const totalDiscount = rows.reduce((sum, o) => sum + ((o.discount_cents as number) || 0), 0);
|
|
// baseCents is pre-discount (line items are at full price, coupon handles discount)
|
|
const baseCents = totalService + totalGovFees;
|
|
|
|
return {
|
|
order: rows[0],
|
|
stripeLineItems,
|
|
service_cents: baseCents,
|
|
base_cents: baseCents,
|
|
discount_cents: totalDiscount,
|
|
customer_email: (rows[0].customer_email as string) || "",
|
|
customer_name: (rows[0].customer_name as string) || "Unknown",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ─── POST /api/v1/checkout/create-session ─────────────────────────────────────
|
|
|
|
router.post("/api/v1/checkout/create-session", async (req, res) => {
|
|
if (!stripe) {
|
|
res.status(503).json({ error: "Payment gateway not configured (STRIPE_SECRET_KEY missing)" });
|
|
return;
|
|
}
|
|
|
|
const parsed = CreateSessionSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ error: "Invalid request", details: parsed.error.flatten() });
|
|
return;
|
|
}
|
|
|
|
const { order_id, order_type, payment_method } = parsed.data;
|
|
|
|
try {
|
|
await ensureColumns();
|
|
|
|
// ── Identity gate (CRTC only) — skip in test mode ───────────────────────
|
|
const isTestMode = STRIPE_SECRET_KEY.startsWith("sk_test_");
|
|
if (order_type === "canada_crtc" && !isTestMode) {
|
|
try {
|
|
const soList = (await getResource(
|
|
"Sales Order",
|
|
undefined,
|
|
{ custom_external_order_id: order_id },
|
|
["name", "custom_identity_status"],
|
|
1,
|
|
)) as Array<{ name: string; custom_identity_status: string }>;
|
|
|
|
if (soList.length > 0) {
|
|
const id_status = soList[0].custom_identity_status;
|
|
if (id_status === "Failed") {
|
|
res.status(422).json({ error: "Identity verification failed.", code: "IDENTITY_FAILED" });
|
|
return;
|
|
}
|
|
if (id_status === "Pending") {
|
|
res.status(422).json({ error: "Identity verification still pending.", code: "IDENTITY_PENDING" });
|
|
return;
|
|
}
|
|
} else {
|
|
const ivCheck = await pool.query(
|
|
`SELECT iv.overall_result
|
|
FROM canada_crtc_orders o
|
|
JOIN identity_verifications iv ON iv.stripe_session_id = o.identity_session_id
|
|
WHERE o.order_number = $1`,
|
|
[order_id],
|
|
);
|
|
if (!ivCheck.rows.length) {
|
|
res.status(422).json({ error: "Identity verification required.", code: "IDENTITY_REQUIRED" });
|
|
return;
|
|
}
|
|
const r = ivCheck.rows[0] as Record<string, unknown>;
|
|
if (r.overall_result === "failed") {
|
|
res.status(422).json({ error: "Identity verification failed.", code: "IDENTITY_FAILED" });
|
|
return;
|
|
}
|
|
if (r.overall_result === "pending") {
|
|
res.status(422).json({ error: "Identity verification still pending.", code: "IDENTITY_PENDING" });
|
|
return;
|
|
}
|
|
}
|
|
} catch (idErr) {
|
|
if (idErr instanceof ERPNextError) {
|
|
console.error("[checkout] ERPNext identity check failed:", idErr);
|
|
res.status(503).json({ error: "Identity service temporarily unavailable.", code: "ERPNEXT_UNAVAILABLE" });
|
|
return;
|
|
}
|
|
throw idErr;
|
|
}
|
|
}
|
|
|
|
// ── Fetch order + build line items ─────────────────────────────────────
|
|
let orderData;
|
|
try {
|
|
orderData = await fetchOrderData(order_id, order_type);
|
|
} catch (err: any) {
|
|
// Intake-not-validated is a normal 422 path; everything else is a
|
|
// genuine error.
|
|
if (err?.code === "INTAKE_NOT_VALIDATED") {
|
|
res.status(422).json({ error: err.message, code: err.code });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
if (!orderData) {
|
|
res.status(404).json({ error: "Order not found or not in pending_payment state" });
|
|
return;
|
|
}
|
|
|
|
const { order, stripeLineItems, service_cents, base_cents, customer_email, customer_name } = orderData;
|
|
// For batch orders, total discount comes from the orderData; for single orders, from the order row
|
|
const discount_cents = (orderData as any).discount_cents ?? (order.discount_cents as number) ?? 0;
|
|
|
|
// ── Server-side surcharge (applied to post-discount amount) ───────────
|
|
const surcharge_pct = GATEWAY_SURCHARGES[payment_method] ?? 0;
|
|
const surcharge_cents = Math.round(((base_cents - discount_cents) * surcharge_pct) / 100);
|
|
const total_cents = base_cents + surcharge_cents - discount_cents;
|
|
|
|
// ── Idempotency — reuse existing open Stripe session only if same method ─
|
|
const existingSessionId = (order.stripe_session_id as string) || null;
|
|
if (existingSessionId && payment_method !== "paypal" && payment_method !== "crypto") {
|
|
try {
|
|
const existing = await stripe.checkout.sessions.retrieve(existingSessionId);
|
|
if (existing.status === "open") {
|
|
// Check if the payment method matches — if user switched, expire the old session
|
|
const existingMethod = existing.metadata?.payment_method;
|
|
if (existingMethod === payment_method) {
|
|
res.json({
|
|
checkout_url: existing.url,
|
|
session_id: existing.id,
|
|
total_cents,
|
|
surcharge_cents,
|
|
surcharge_pct,
|
|
});
|
|
return;
|
|
}
|
|
// Different method requested — expire old session
|
|
try {
|
|
await stripe.checkout.sessions.expire(existingSessionId);
|
|
console.log(`[checkout] Expired old session ${existingSessionId} (switching ${existingMethod} → ${payment_method})`);
|
|
} catch { /* already expired or completed */ }
|
|
}
|
|
// expired, complete, or method changed — fall through to create new
|
|
} catch {
|
|
// Session not found or expired — fall through
|
|
}
|
|
}
|
|
// Clear stale session ID when switching to non-Stripe gateway
|
|
if (existingSessionId && (payment_method === "paypal" || payment_method === "crypto")) {
|
|
try {
|
|
const existing = await stripe.checkout.sessions.retrieve(existingSessionId);
|
|
if (existing.status === "open") {
|
|
await stripe.checkout.sessions.expire(existingSessionId);
|
|
console.log(`[checkout] Expired Stripe session ${existingSessionId} (switching to ${payment_method})`);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// ── Add surcharge line item if applicable ──────────────────────────────
|
|
const allLineItems = [...stripeLineItems];
|
|
if (surcharge_cents > 0) {
|
|
allLineItems.push({
|
|
price_data: {
|
|
currency: "usd",
|
|
product_data: {
|
|
name: `${GATEWAY_LABELS[payment_method]} Processing Fee`,
|
|
description: `${surcharge_pct}% processing surcharge`,
|
|
},
|
|
unit_amount: surcharge_cents,
|
|
},
|
|
quantity: 1,
|
|
});
|
|
}
|
|
|
|
// ── Apply discount coupon if applicable ────────────────────────────────
|
|
let discounts: Stripe.Checkout.SessionCreateParams.Discount[] | undefined;
|
|
if (discount_cents > 0) {
|
|
// Create a one-time Stripe coupon for this exact discount amount
|
|
const coupon = await stripe.coupons.create({
|
|
amount_off: discount_cents,
|
|
currency: "usd",
|
|
duration: "once",
|
|
name: `Order discount (${order_id})`,
|
|
metadata: { order_id, order_type },
|
|
});
|
|
discounts = [{ coupon: coupon.id }];
|
|
}
|
|
|
|
// ── Find or create ERPNext Customer (non-blocking for checkout) ────────
|
|
let erpnextCustomer: string | undefined;
|
|
let portalUserCreated = false;
|
|
try {
|
|
const { customerName, portalUserCreated: created } =
|
|
await findOrCreateCustomer(customer_email, customer_name);
|
|
erpnextCustomer = customerName;
|
|
portalUserCreated = created;
|
|
} catch (err) {
|
|
console.warn("[checkout] ERPNext customer sync failed (non-blocking):", err);
|
|
}
|
|
|
|
// Persist the portal-user-created flag so the success page + delivery
|
|
// worker can surface the set-password onboarding (migration 047). The
|
|
// table depends on order_type — do the correct UPDATE per type.
|
|
if (portalUserCreated) {
|
|
try {
|
|
const table =
|
|
order_type === "canada_crtc"
|
|
? "canada_crtc_orders"
|
|
: order_type === "formation"
|
|
? "formation_orders"
|
|
: order_type === "compliance" || order_type === "compliance_batch"
|
|
? "compliance_orders"
|
|
: null;
|
|
if (table) {
|
|
await pool.query(
|
|
`UPDATE ${table} SET portal_user_created = TRUE WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
}
|
|
} catch (pgErr) {
|
|
console.warn("[checkout] Could not persist portal_user_created flag:", pgErr);
|
|
}
|
|
}
|
|
|
|
// ── Create ERPNext Sales Order (non-blocking, all payment methods) ────
|
|
if (order_type === "canada_crtc" && erpnextCustomer) {
|
|
try {
|
|
// Fetch binder mailing details from the PG order row (already loaded above)
|
|
const pgOrder = orderData.order as Record<string, any>;
|
|
const hasOwnAddr = pgOrder.has_own_ca_address === true;
|
|
const ownCaCompany = pgOrder.own_ca_company as string | null;
|
|
const ownCaAttn = pgOrder.own_ca_attn as string | null;
|
|
// For AMB orders, fetch operator_name from amb_locations
|
|
let ambOperatorName: string | null = null;
|
|
if (!hasOwnAddr && pgOrder.amb_location_slug) {
|
|
try {
|
|
const { rows: ambOp } = await pool.query(
|
|
"SELECT operator_name FROM amb_locations WHERE slug = $1",
|
|
[pgOrder.amb_location_slug],
|
|
);
|
|
if (ambOp.length > 0) ambOperatorName = ambOp[0].operator_name;
|
|
} catch { /* non-fatal */ }
|
|
}
|
|
|
|
const so = (await createResource("Sales Order", {
|
|
customer: erpnextCustomer,
|
|
delivery_date: new Date(Date.now() + 90 * 86400000).toISOString().split("T")[0],
|
|
custom_external_order_id: order_id,
|
|
custom_order_type: order_type,
|
|
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
|
|
custom_surcharge_pct: surcharge_pct,
|
|
custom_identity_status: "Verified",
|
|
// Binder mailing address details
|
|
custom_mailbox_address: pgOrder.mailbox_address || null,
|
|
custom_has_own_ca_address: hasOwnAddr ? 1 : 0,
|
|
custom_own_ca_company: hasOwnAddr ? (ownCaCompany || null) : (ambOperatorName || null),
|
|
custom_own_ca_attn: hasOwnAddr ? (ownCaAttn || null) : null,
|
|
workflow_state: "Received",
|
|
items: [{
|
|
item_code: "CRTC-PACKAGE",
|
|
qty: 1,
|
|
rate: toDollars(base_cents),
|
|
}, ...(surcharge_cents > 0 ? [{
|
|
item_code: "PAYMENT-PROCESSING-FEE",
|
|
description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`,
|
|
qty: 1,
|
|
rate: toDollars(surcharge_cents),
|
|
}] : [])],
|
|
})) as { name: string };
|
|
|
|
try {
|
|
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
|
|
} catch { /* submit may fail if workflow doesn't require it */ }
|
|
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET erpnext_sales_order = $1 WHERE order_number = $2`,
|
|
[so.name, order_id],
|
|
);
|
|
|
|
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for ${order_id}`);
|
|
} catch (soErr) {
|
|
console.warn("[checkout] ERPNext Sales Order creation failed (non-blocking):", soErr);
|
|
}
|
|
}
|
|
|
|
// ── Create ERPNext Sales Order (compliance services) ────────────────────
|
|
if (order_type === "compliance" && erpnextCustomer) {
|
|
try {
|
|
const { COMPLIANCE_SERVICES } = await import("./compliance-orders.js");
|
|
const pgOrder = orderData.order as Record<string, any>;
|
|
const serviceSlug = (pgOrder.service_slug as string) || "";
|
|
const serviceInfo = COMPLIANCE_SERVICES[serviceSlug];
|
|
const itemCode = serviceInfo?.erpnext_item || "COMPLIANCE-SERVICE";
|
|
|
|
const so = (await createResource("Sales Order", {
|
|
customer: erpnextCustomer,
|
|
delivery_date: new Date(Date.now() + 30 * 86400000).toISOString().split("T")[0],
|
|
custom_external_order_id: order_id,
|
|
custom_order_type: "compliance",
|
|
custom_payment_gateway: GATEWAY_LABELS[payment_method] || payment_method,
|
|
custom_surcharge_pct: surcharge_pct,
|
|
workflow_state: "Received",
|
|
items: [{
|
|
item_code: itemCode,
|
|
description: pgOrder.service_name || serviceInfo?.name || "Compliance Service",
|
|
qty: 1,
|
|
rate: toDollars(base_cents),
|
|
}, ...(surcharge_cents > 0 ? [{
|
|
item_code: "PAYMENT-PROCESSING-FEE",
|
|
description: `${GATEWAY_LABELS[payment_method] || payment_method} ${surcharge_pct}%`,
|
|
qty: 1,
|
|
rate: toDollars(surcharge_cents),
|
|
}] : [])],
|
|
})) as { name: string };
|
|
|
|
try {
|
|
await callMethod("frappe.client.submit", { doc: { doctype: "Sales Order", name: so.name } });
|
|
} catch { /* submit may fail if workflow doesn't require it */ }
|
|
|
|
await pool.query(
|
|
`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE order_number = $2`,
|
|
[so.name, order_id],
|
|
);
|
|
|
|
console.log(`[checkout] Created ERPNext Sales Order ${so.name} for compliance ${order_id}`);
|
|
} catch (soErr) {
|
|
console.warn("[checkout] Compliance Sales Order creation failed (non-blocking):", soErr);
|
|
}
|
|
}
|
|
|
|
// ── Create Stripe Checkout Session ─────────────────────────────────────
|
|
const STRIPE_PAYMENT_METHOD_MAP: Record<string, Stripe.Checkout.SessionCreateParams.PaymentMethodType[]> = {
|
|
card: ["card"],
|
|
ach: ["us_bank_account"],
|
|
klarna: ["klarna"],
|
|
paypal: [], // PayPal direct — bypasses Stripe
|
|
crypto: [], // SHKeeper — bypasses Stripe
|
|
};
|
|
const paymentMethodTypes = STRIPE_PAYMENT_METHOD_MAP[payment_method] ?? ["card"];
|
|
|
|
// ── $0 orders: skip all payment gateways, mark paid immediately ──────
|
|
const effectiveTotal = base_cents - discount_cents;
|
|
if (effectiveTotal <= 0 && base_cents > 0) {
|
|
const zeroTable: Record<string, string> = {
|
|
canada_crtc: "canada_crtc_orders",
|
|
formation: "formation_orders",
|
|
bundle: "bundle_orders",
|
|
compliance: "compliance_orders",
|
|
compliance_batch: "compliance_orders",
|
|
};
|
|
const zt = zeroTable[order_type];
|
|
if (zt) {
|
|
const whereCol = order_type === "compliance_batch" ? "batch_id" : "order_number";
|
|
await pool.query(
|
|
`UPDATE ${zt} SET payment_status = 'paid', payment_method = 'free', surcharge_cents = 0, surcharge_pct = 0 WHERE ${whereCol} = $1`,
|
|
[order_id],
|
|
);
|
|
}
|
|
console.log(`[checkout] $0 order — skipping payment for ${order_type} ${order_id} (discount ${discount_cents} >= base ${base_cents})`);
|
|
res.json({
|
|
checkout_url: `${DOMAIN}/order/success?order_id=${order_id}&order_type=${order_type}&free=1`,
|
|
session_id: null,
|
|
total_cents: 0,
|
|
surcharge_cents: 0,
|
|
surcharge_pct: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (payment_method === "crypto") {
|
|
// Crypto orders bypass Stripe — create SHKeeper invoice via API
|
|
const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000";
|
|
const shkeeperPublic = process.env.SHKEEPER_PUBLIC_URL || "https://crypto.performancewest.net";
|
|
const shkeeperKey = process.env.SHKEEPER_API_KEY || "";
|
|
const callbackUrl = `${DOMAIN}/api/v1/webhooks/shkeeper`;
|
|
|
|
if (!shkeeperKey) {
|
|
res.status(503).json({ error: "Cryptocurrency payments are not configured. Please choose another payment method." });
|
|
return;
|
|
}
|
|
|
|
// Mark order as crypto and redirect to our crypto-pay page
|
|
// The actual SHKeeper invoice is created when the user picks a coin
|
|
const table = order_type === "canada_crtc" ? "canada_crtc_orders"
|
|
: order_type === "formation" ? "formation_orders"
|
|
: "bundle_orders";
|
|
await pool.query(
|
|
`UPDATE ${table} SET payment_method = 'crypto' WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
|
|
const paymentPageUrl = `${DOMAIN}/order/crypto-pay?order_id=${order_id}&order_type=${order_type}&amount=${toDollars(total_cents)}${order.expedited ? "&expedited=1" : ""}`;
|
|
res.json({
|
|
checkout_url: paymentPageUrl,
|
|
session_id: `crypto-${order_id}`,
|
|
total_cents,
|
|
surcharge_cents: 0,
|
|
surcharge_pct: 0,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (payment_method === "paypal") {
|
|
// PayPal direct — redirect to PayPal checkout
|
|
const paypalClientId = process.env.PAYPAL_CLIENT_ID || "";
|
|
const paypalBaseUrl = process.env.PAYPAL_API_URL || "https://api-m.paypal.com";
|
|
const paypalSecret = process.env.PAYPAL_CLIENT_SECRET || "";
|
|
|
|
if (!paypalClientId || !paypalSecret) {
|
|
res.status(503).json({ error: "PayPal is not configured. Please choose another payment method." });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Get PayPal access token
|
|
const authRes = await fetch(`${paypalBaseUrl}/v1/oauth2/token`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Basic ${Buffer.from(`${paypalClientId}:${paypalSecret}`).toString("base64")}`,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
},
|
|
body: "grant_type=client_credentials",
|
|
});
|
|
const authData = await authRes.json() as { access_token?: string };
|
|
if (!authData.access_token) throw new Error("PayPal auth failed");
|
|
|
|
// Create PayPal order
|
|
const ppOrder = await fetch(`${paypalBaseUrl}/v2/checkout/orders`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${authData.access_token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
intent: "CAPTURE",
|
|
purchase_units: [{
|
|
reference_id: order_id,
|
|
description: `Performance West — ${order_type === "canada_crtc" ? "Canada CRTC Package" : "Order"} ${order_id}`,
|
|
amount: {
|
|
currency_code: "USD",
|
|
value: toDollars(total_cents),
|
|
},
|
|
custom_id: order_id,
|
|
}],
|
|
payment_source: {
|
|
paypal: {
|
|
experience_context: {
|
|
brand_name: "Performance West Inc.",
|
|
return_url: `${DOMAIN}/order/success?paypal=1&order_id=${order_id}&order_type=${order_type}`,
|
|
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
|
|
user_action: "PAY_NOW",
|
|
landing_page: "LOGIN",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
});
|
|
const ppData = await ppOrder.json() as { id?: string; links?: Array<{ rel: string; href: string }> };
|
|
const approveLink = ppData.links?.find(l => l.rel === "payer-action")?.href
|
|
|| ppData.links?.find(l => l.rel === "approve")?.href;
|
|
|
|
if (!approveLink) throw new Error("PayPal order creation failed — no approval URL");
|
|
|
|
// Store PayPal order ID for capture on return
|
|
await pool.query(
|
|
`UPDATE ${order_type === "canada_crtc" ? "canada_crtc_orders" : "formation_orders"}
|
|
SET paypal_order_id = $1, payment_method = 'paypal'
|
|
WHERE order_number = $2`,
|
|
[ppData.id, order_id],
|
|
);
|
|
|
|
res.json({
|
|
checkout_url: approveLink,
|
|
session_id: `paypal-${ppData.id}`,
|
|
total_cents,
|
|
surcharge_cents,
|
|
surcharge_pct,
|
|
});
|
|
} catch (ppErr) {
|
|
console.error("[checkout] PayPal order creation failed:", ppErr);
|
|
res.status(502).json({ error: "PayPal checkout failed. Please try another payment method." });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const paymentIntentData: Stripe.Checkout.SessionCreateParams.PaymentIntentData = {
|
|
metadata: {
|
|
order_id,
|
|
order_type,
|
|
payment_method,
|
|
},
|
|
...(payment_method === "ach" ? { setup_future_usage: "off_session" } : {}),
|
|
};
|
|
|
|
const session = await stripe.checkout.sessions.create({
|
|
mode: "payment",
|
|
payment_method_types: paymentMethodTypes,
|
|
line_items: allLineItems,
|
|
...(discounts ? { discounts } : {}),
|
|
customer_email: customer_email || undefined,
|
|
success_url: `${DOMAIN}/order/success?session_id={CHECKOUT_SESSION_ID}&order_id=${order_id}&order_type=${order_type}`,
|
|
cancel_url: `${DOMAIN}/order/cancelled?order_id=${order_id}&order_type=${order_type}${order.expedited ? "&expedited=1" : ""}`,
|
|
metadata: {
|
|
order_id,
|
|
order_type,
|
|
payment_method,
|
|
customer_email: customer_email || "",
|
|
customer_name: customer_name || "",
|
|
...(erpnextCustomer ? { erpnext_customer: erpnextCustomer } : {}),
|
|
},
|
|
payment_intent_data: paymentIntentData,
|
|
// ACH via Plaid: verify account balance before accepting payment
|
|
...(payment_method === "ach" ? {
|
|
payment_method_options: {
|
|
us_bank_account: {
|
|
financial_connections: {
|
|
permissions: ["payment_method", "balances"],
|
|
prefetch: ["balances"],
|
|
},
|
|
verification_method: "instant",
|
|
},
|
|
},
|
|
} : {}),
|
|
});
|
|
|
|
// (Sales Order already created above, before gateway split)
|
|
|
|
// ── Record Stripe session on PG order ──────────────────────────────────
|
|
const tableMap: Record<string, string> = {
|
|
canada_crtc: "canada_crtc_orders",
|
|
formation: "formation_orders",
|
|
bundle: "bundle_orders",
|
|
compliance: "compliance_orders",
|
|
compliance_batch: "compliance_orders",
|
|
};
|
|
const table = tableMap[order_type];
|
|
if (table) {
|
|
// For batch orders, update by batch_id; otherwise by order_number
|
|
const whereCol = order_type === "compliance_batch" ? "batch_id" : "order_number";
|
|
await pool.query(
|
|
`UPDATE ${table}
|
|
SET stripe_session_id = $1,
|
|
payment_method = $2,
|
|
surcharge_pct = $3,
|
|
surcharge_cents = $4
|
|
WHERE ${whereCol} = $5`,
|
|
[session.id, payment_method, surcharge_pct, surcharge_cents, order_id],
|
|
);
|
|
}
|
|
|
|
console.log(`[checkout] Stripe session ${session.id} created for ${order_type} ${order_id}`);
|
|
|
|
res.json({
|
|
checkout_url: session.url,
|
|
session_id: session.id,
|
|
total_cents,
|
|
surcharge_cents,
|
|
surcharge_pct,
|
|
});
|
|
} catch (err) {
|
|
console.error("[checkout] create-session error:", err);
|
|
res.status(500).json({ error: "Failed to create checkout session" });
|
|
}
|
|
});
|
|
|
|
// ─── GET /api/v1/checkout/session/:session_id ─────────────────────────────────
|
|
// Poll this after customer returns from Stripe checkout.
|
|
|
|
router.get("/api/v1/checkout/session/:session_id", async (req, res) => {
|
|
if (!stripe) {
|
|
res.status(503).json({ error: "Payment gateway not configured" });
|
|
return;
|
|
}
|
|
|
|
const { session_id } = req.params;
|
|
if (!session_id) {
|
|
res.status(400).json({ error: "session_id required" });
|
|
return;
|
|
}
|
|
|
|
// Crypto pseudo-sessions don't go through Stripe
|
|
if (session_id.startsWith("crypto-")) {
|
|
const order_id = session_id.replace("crypto-", "");
|
|
res.json({ status: "pending", session_id, order_id, order_type: "unknown", total_cents: null });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const session = await stripe.checkout.sessions.retrieve(session_id, {
|
|
expand: ["payment_intent"],
|
|
});
|
|
|
|
const statusMap: Record<string, string> = {
|
|
open: "pending",
|
|
complete: "paid",
|
|
expired: "expired",
|
|
};
|
|
const status = statusMap[session.status ?? "open"] ?? "pending";
|
|
|
|
// Resolve order from metadata
|
|
const order_id = session.metadata?.order_id ?? null;
|
|
const order_type = session.metadata?.order_type ?? null;
|
|
|
|
// ── On payment completion: update PG + advance ERPNext + send confirmation ──
|
|
if (status === "paid" && order_id && order_type) {
|
|
await handlePaymentComplete(order_id, order_type, session_id);
|
|
}
|
|
|
|
res.json({
|
|
status,
|
|
session_id,
|
|
order_id,
|
|
order_type,
|
|
total_cents: session.amount_total,
|
|
customer_email: session.customer_details?.email || session.metadata?.customer_email || null,
|
|
customer_name: session.customer_details?.name || session.metadata?.customer_name || null,
|
|
});
|
|
} catch (err) {
|
|
console.error("[checkout] session poll error:", err);
|
|
res.status(500).json({ error: "Failed to retrieve session status" });
|
|
}
|
|
});
|
|
|
|
// ─── Shared payment-complete handler ─────────────────────────────────────────
|
|
// Called from both poll endpoint and Stripe webhook.
|
|
|
|
export async function handlePaymentComplete(
|
|
order_id: string,
|
|
order_type: string,
|
|
session_id: string,
|
|
): Promise<void> {
|
|
const tableMap: Record<string, string> = {
|
|
canada_crtc: "canada_crtc_orders",
|
|
formation: "formation_orders",
|
|
bundle: "bundle_orders",
|
|
compliance: "compliance_orders",
|
|
compliance_batch: "compliance_orders",
|
|
fcc_carrier_registration: "fcc_carrier_registrations",
|
|
};
|
|
const table = tableMap[order_type];
|
|
if (!table) return;
|
|
|
|
// For batch orders, update by batch_id instead of order_number
|
|
const updated = order_type === "compliance_batch"
|
|
? await pool.query(
|
|
`UPDATE ${table}
|
|
SET payment_status = 'paid',
|
|
paid_at = NOW()
|
|
WHERE batch_id = $1
|
|
AND payment_status IN ('pending_payment', 'received')
|
|
RETURNING *`,
|
|
[order_id],
|
|
)
|
|
: await pool.query(
|
|
`UPDATE ${table}
|
|
SET payment_status = 'paid',
|
|
paid_at = NOW()
|
|
WHERE order_number = $1
|
|
AND payment_status IN ('pending_payment', 'received')
|
|
RETURNING *`,
|
|
[order_id],
|
|
);
|
|
|
|
if (!updated.rows.length) {
|
|
// Already processed
|
|
return;
|
|
}
|
|
|
|
const order = updated.rows[0] as Record<string, unknown>;
|
|
const paymentMethod = (order.payment_method as string) || "card";
|
|
console.log(`[checkout] Payment confirmed: ${order_type} ${order_id} via ${paymentMethod}`);
|
|
|
|
// ── Telegram order notification ──────────────────────────────────────
|
|
try {
|
|
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
|
const chatId = process.env.TELEGRAM_CHAT_ID;
|
|
if (botToken && chatId) {
|
|
const customerName = (order.customer_name as string) || "Unknown";
|
|
const customerEmail = (order.customer_email as string) || "";
|
|
const serviceName = (order.service_slug as string)
|
|
|| (order.service_name as string)
|
|
|| order_type;
|
|
// Calculate total in dollars
|
|
const feeCents = Number(order.service_fee_cents || order.total_cents || 0);
|
|
const formationCents = Number(order.formation_fee_cents || 0);
|
|
const stateCents = Number(order.state_fee_cents || 0);
|
|
const addonCents = Number(order.addon_fee_cents || 0);
|
|
const pucCents = Number(order.puc_fee_cents || 0);
|
|
const totalCents = feeCents + formationCents + stateCents + addonCents + pucCents;
|
|
const totalDollars = (totalCents / 100).toFixed(2);
|
|
|
|
const msg = `💰 NEW ORDER\n\n`
|
|
+ `Customer: ${customerName}\n`
|
|
+ `Email: ${customerEmail}\n`
|
|
+ `Service: ${serviceName}\n`
|
|
+ `Total: $${totalDollars}\n`
|
|
+ `Payment: ${paymentMethod}\n`
|
|
+ `Order: ${order_id}\n`
|
|
+ `Type: ${order_type}`;
|
|
|
|
fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ chat_id: chatId, text: msg }),
|
|
}).catch(() => {}); // fire and forget
|
|
}
|
|
} catch {}
|
|
|
|
// ── CDR traffic study paywall: issue grants on qualifying orders ──────
|
|
//
|
|
// When a customer pays for a 499-A filing service OR the standalone
|
|
// cdr-analysis service, unlock the classified traffic study for that
|
|
// reporting year. Non-fatal — the customer still sees ingestion
|
|
// counts even without a grant.
|
|
if (order_type === "compliance" || order_type === "compliance_batch") {
|
|
try {
|
|
await grantCDRStudyAccess(order, order_id);
|
|
} catch (grantErr) {
|
|
console.warn("[checkout] CDR grant issuance warning (non-fatal):", grantErr);
|
|
}
|
|
|
|
// Send intake/next-steps email for compliance orders
|
|
try {
|
|
await sendComplianceIntakeEmail(order_id, order_type, updated.rows);
|
|
} catch (intakeErr) {
|
|
console.error("[checkout] Compliance intake email failed (non-fatal):", intakeErr);
|
|
}
|
|
}
|
|
|
|
// ── Umami analytics — server-side payment event ─────────────────────────
|
|
try {
|
|
const umamiUrl = process.env.UMAMI_URL || "http://umami:3000";
|
|
const websiteId = "55250014-ee15-44ac-a1f6-81dabad3fe0f";
|
|
await fetch(`${umamiUrl}/api/send`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "User-Agent": "PW-API/1.0" },
|
|
body: JSON.stringify({
|
|
payload: {
|
|
website: websiteId,
|
|
hostname: "performancewest.net",
|
|
url: `/checkout/complete/${order_type}`,
|
|
name: "payment-complete",
|
|
data: {
|
|
order_type,
|
|
order_id,
|
|
payment_method: paymentMethod,
|
|
total_cents: Number(order.service_fee_cents || order.total_cents || 0),
|
|
service: (order.service_slug as string) || order_type,
|
|
},
|
|
},
|
|
type: "event",
|
|
}),
|
|
});
|
|
} catch { /* non-fatal */ }
|
|
|
|
// ── Advance ERPNext Sales Order workflow (CRTC) ────────────────────────
|
|
if (order_type === "canada_crtc") {
|
|
const soName = (order.erpnext_sales_order as string) || null;
|
|
|
|
// PayPal: funds are instant — advance directly to "Client Selection"
|
|
// Stripe card/ACH/Klarna: advance to "Awaiting Funds" — balance.available webhook handles next step
|
|
// Crypto: advance to "Awaiting Funds" — manual admin step later
|
|
const isInstantFunds = paymentMethod === "paypal";
|
|
const targetState = isInstantFunds ? "Client Selection" : "Awaiting Funds";
|
|
|
|
if (soName) {
|
|
try {
|
|
await callMethod("frappe.client.set_value", {
|
|
doctype: "Sales Order",
|
|
name: soName,
|
|
fieldname: "workflow_state",
|
|
value: targetState,
|
|
});
|
|
console.log(`[checkout] Advanced Sales Order ${soName} to ${targetState}`);
|
|
} catch (wfErr) {
|
|
console.error(`[checkout] Workflow advance failed for ${soName}:`, wfErr);
|
|
}
|
|
}
|
|
|
|
// If instant funds, also mark funds_available and send portal setup email
|
|
if (isInstantFunds) {
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET funds_available = TRUE, funds_available_at = NOW() WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
try {
|
|
await sendClientSelectionEmail(order_id, order);
|
|
} catch (emailErr) {
|
|
console.error("[checkout] Client selection email failed (non-blocking):", emailErr);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Advance compliance batch orders (fan out worker dispatch per service) ──
|
|
// ── FCC Carrier Registration — dispatch pipeline worker ──────────────────
|
|
if (order_type === "fcc_carrier_registration") {
|
|
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
|
|
setImmediate(async () => {
|
|
try {
|
|
const dispatchRes = await fetch(`${workerUrl}/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "process_fcc_carrier_registration",
|
|
order_number: order_id,
|
|
}),
|
|
});
|
|
if (dispatchRes.ok) {
|
|
console.log(`[checkout] FCC carrier registration dispatched: ${order_id}`);
|
|
} else {
|
|
console.error(`[checkout] FCC carrier reg dispatch failed: HTTP ${dispatchRes.status}`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[checkout] FCC carrier reg dispatch error:`, err);
|
|
}
|
|
});
|
|
|
|
// Send confirmation email
|
|
try {
|
|
const { sendEmail } = await import("../email.js");
|
|
const custEmail = (order.customer_email as string) || "";
|
|
const custName = (order.customer_name as string) || "";
|
|
const firstName = custName.split(" ")[0] || custName;
|
|
await sendEmail({
|
|
to: custEmail,
|
|
subject: `FCC Carrier Registration — Order ${order_id} Confirmed`,
|
|
html: `<h2>Your FCC Carrier Registration is Underway</h2>
|
|
<p>Hi ${firstName},</p>
|
|
<p>Thank you for your order. We're now processing your carrier/ISP registration package.</p>
|
|
<p>You'll receive email updates as each step completes. If we need any additional information, we'll reach out.</p>
|
|
<p style="font-size:12px;color:#9ca3af;">Order: ${order_id}</p>
|
|
<p style="font-size:12px;color:#9ca3af;">Performance West Inc. | 525 Randall Ave Ste 100-1195, Cheyenne, WY 82001 | 1-888-411-0383</p>`,
|
|
});
|
|
} catch {}
|
|
}
|
|
|
|
if (order_type === "compliance_batch") {
|
|
// A batch order is a set of compliance_orders sharing a batch_id.
|
|
// Create ERPNext Sales Order + Invoice, then dispatch each sub-order.
|
|
const batchId = order_id;
|
|
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
|
|
try {
|
|
const batchRows = await pool.query(
|
|
`SELECT order_number, service_slug, service_name, service_fee_cents,
|
|
discount_cents, gov_fee_cents, erpnext_sales_order
|
|
FROM compliance_orders WHERE batch_id = $1`,
|
|
[batchId],
|
|
);
|
|
console.log(`[checkout] Batch ${batchId}: ${batchRows.rows.length} orders to dispatch`);
|
|
|
|
// ── Create ERPNext Sales Order for the batch (non-blocking) ──────
|
|
setImmediate(async () => {
|
|
try {
|
|
// Find or create ERPNext Customer
|
|
const custEmail = (order.customer_email as string) || "";
|
|
const custName = (order.customer_name as string) || custEmail;
|
|
let erpCustomer: string | undefined;
|
|
try {
|
|
const custResult = await findOrCreateCustomer(custEmail, custName);
|
|
erpCustomer = custResult.customerName;
|
|
// Link portal user to Customer so portal shows their orders
|
|
try {
|
|
await updateResource("Customer", erpCustomer!, {
|
|
portal_users: [{ user: custEmail }],
|
|
});
|
|
} catch { /* non-fatal — may already be linked */ }
|
|
} catch { /* non-fatal */ }
|
|
|
|
// Build line items for ERPNext — show full price with discount on each line
|
|
const items: Record<string, unknown>[] = [];
|
|
for (const bo of batchRows.rows as any[]) {
|
|
const priceCents = (bo.service_fee_cents || 0) + (bo.gov_fee_cents || 0);
|
|
const discCents = bo.discount_cents || 0;
|
|
items.push({
|
|
item_code: bo.service_slug,
|
|
item_name: bo.service_name || bo.service_slug,
|
|
qty: 1,
|
|
rate: priceCents / 100,
|
|
discount_amount: discCents / 100,
|
|
description: bo.service_name +
|
|
(bo.gov_fee_cents > 0 ? ` (includes $${(bo.gov_fee_cents / 100).toFixed(0)} gov filing fee)` : ""),
|
|
});
|
|
}
|
|
|
|
const soData: Record<string, unknown> = {
|
|
customer: erpCustomer || custName,
|
|
title: `FCC Compliance Services — ${batchId}`,
|
|
custom_external_order_id: batchId,
|
|
delivery_date: new Date(Date.now() + 10 * 86400000).toISOString().slice(0, 10),
|
|
items,
|
|
custom_payment_gateway: paymentMethod,
|
|
};
|
|
|
|
const so = await createResource("Sales Order", soData) as Record<string, any>;
|
|
const soName = so.name;
|
|
console.log(`[checkout] ERPNext Sales Order ${soName} created for batch ${batchId}`);
|
|
|
|
// Submit the Sales Order (fetch fresh doc first to avoid timestamp mismatch)
|
|
try {
|
|
const freshDoc = await getResource("Sales Order", soName) as Record<string, any>;
|
|
await callMethod("frappe.client.submit", { doc: JSON.stringify(freshDoc) });
|
|
} catch (submitErr) {
|
|
console.warn(`[checkout] SO submit failed for ${soName} (non-fatal):`, (submitErr as any)?.body?.exception || submitErr);
|
|
}
|
|
|
|
// Link the SO back to PG orders
|
|
await pool.query(
|
|
`UPDATE compliance_orders SET erpnext_sales_order = $1 WHERE batch_id = $2`,
|
|
[soName, batchId],
|
|
);
|
|
|
|
// Create Sales Invoice
|
|
try {
|
|
// Calculate batch total (sum of all line items minus discounts)
|
|
const batchTotalCents = (batchRows.rows as any[]).reduce((sum, bo) => {
|
|
return sum + (bo.service_fee_cents || 0) + (bo.gov_fee_cents || 0) - (bo.discount_cents || 0);
|
|
}, 0);
|
|
const invName = await createInvoiceFromSalesOrder({
|
|
salesOrderName: soName,
|
|
paymentGateway: paymentMethod,
|
|
surchargePercent: (order.surcharge_pct as number) || 0,
|
|
paymentReference: session_id,
|
|
paidAmountCents: batchTotalCents,
|
|
externalOrderId: batchId,
|
|
});
|
|
if (invName) {
|
|
console.log(`[checkout] ERPNext Invoice ${invName} created for batch ${batchId}`);
|
|
}
|
|
} catch (invErr) {
|
|
console.warn(`[checkout] Invoice creation failed for ${batchId} (non-fatal):`, invErr);
|
|
}
|
|
} catch (erpErr) {
|
|
console.warn(`[checkout] ERPNext SO creation failed for batch ${batchId} (non-fatal):`, erpErr);
|
|
}
|
|
});
|
|
|
|
for (const batchOrder of batchRows.rows) {
|
|
const bo = batchOrder as Record<string, unknown>;
|
|
const boNumber = bo.order_number as string;
|
|
const boSlug = bo.service_slug as string;
|
|
const boSO = bo.erpnext_sales_order as string | null;
|
|
|
|
// Advance ERPNext Sales Order workflow if linked
|
|
if (boSO) {
|
|
try {
|
|
await callMethod("frappe.client.set_value", {
|
|
doctype: "Sales Order",
|
|
name: boSO,
|
|
fieldname: "workflow_state",
|
|
value: "Service Queued",
|
|
});
|
|
} catch (wfErr) {
|
|
console.error(`[checkout] Batch ${batchId}: workflow advance failed for ${boNumber}:`, wfErr);
|
|
}
|
|
}
|
|
|
|
// Dispatch to worker
|
|
setImmediate(async () => {
|
|
try {
|
|
const res = await fetch(`${workerUrl}/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "process_compliance_service",
|
|
order_name: boSO || boNumber,
|
|
order_number: boNumber,
|
|
service_slug: boSlug,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
console.error(`[checkout] Batch dispatch failed for ${boNumber}: HTTP ${res.status}`);
|
|
} else {
|
|
console.log(`[checkout] Batch dispatched: ${boNumber} (${boSlug})`);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[checkout] Batch dispatch error for ${boNumber}:`, err);
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error(`[checkout] Batch ${batchId}: failed to load sub-orders:`, err);
|
|
}
|
|
|
|
// ── Commission tracking for compliance batch orders ─────────────────
|
|
try {
|
|
const discRow = await pool.query(
|
|
`SELECT DISTINCT discount_code FROM compliance_orders WHERE batch_id = $1 AND discount_code IS NOT NULL LIMIT 1`,
|
|
[batchId],
|
|
);
|
|
const discountCode = discRow.rows[0]?.discount_code as string | null;
|
|
if (discountCode) {
|
|
const { createCommission } = await import("./agents.js");
|
|
const totalCents = (updated.rows as any[]).reduce((sum, r) =>
|
|
sum + (Number(r.service_fee_cents) || 0) + (Number(r.gov_fee_cents) || 0) - (Number(r.discount_cents) || 0), 0);
|
|
await createCommission({
|
|
agentCode: discountCode,
|
|
orderType: "service",
|
|
orderId: 0,
|
|
orderNumber: batchId,
|
|
serviceSlug: "compliance-batch",
|
|
customerName: (order.customer_name as string) || "",
|
|
customerEmail: (order.customer_email as string) || "",
|
|
orderAmountCents: totalCents,
|
|
discountCents: (updated.rows as any[]).reduce((sum, r) => sum + (Number(r.discount_cents) || 0), 0),
|
|
});
|
|
console.log(`[checkout] Commission created for batch ${batchId} via ${discountCode}`);
|
|
}
|
|
} catch (commErr) {
|
|
console.warn("[checkout] Commission creation failed (non-fatal):", commErr);
|
|
}
|
|
}
|
|
|
|
// ── Advance compliance order workflow (queues document generation) ────────
|
|
if (order_type === "compliance") {
|
|
const soName = (order.erpnext_sales_order as string) || null;
|
|
if (soName) {
|
|
try {
|
|
await callMethod("frappe.client.set_value", {
|
|
doctype: "Sales Order",
|
|
name: soName,
|
|
fieldname: "workflow_state",
|
|
value: "Service Queued",
|
|
});
|
|
console.log(`[checkout] Advanced compliance Sales Order ${soName} to Service Queued`);
|
|
} catch (wfErr) {
|
|
console.error(`[checkout] Compliance workflow advance failed for ${soName}:`, wfErr);
|
|
}
|
|
}
|
|
|
|
// ── Dispatch to worker directly (do not depend on an ERPNext Webhook) ──
|
|
// The worker service is the source of truth for fulfilment; we call it
|
|
// straight from here so a missing Frappe Webhook fixture never strands
|
|
// a paid order.
|
|
const serviceSlug = (order.service_slug as string) || "";
|
|
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
|
|
|
|
setImmediate(async () => {
|
|
try {
|
|
const dispatchRes = await fetch(`${workerUrl}/jobs`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
action: "process_compliance_service",
|
|
order_name: soName,
|
|
order_number: order_id,
|
|
service_slug: serviceSlug,
|
|
}),
|
|
});
|
|
if (!dispatchRes.ok) {
|
|
console.error(
|
|
`[checkout] Worker dispatch failed for ${order_id}: HTTP ${dispatchRes.status}`,
|
|
);
|
|
} else {
|
|
console.log(`[checkout] Worker dispatched: ${order_id} (${serviceSlug})`);
|
|
}
|
|
} catch (dispatchErr) {
|
|
console.error(`[checkout] Worker dispatch error for ${order_id}:`, dispatchErr);
|
|
}
|
|
});
|
|
|
|
// Create invoice (non-blocking)
|
|
if (soName) {
|
|
setImmediate(async () => {
|
|
try {
|
|
const surchargePct = (order.surcharge_pct as number) || 0;
|
|
const totalCents = (order.service_fee_cents as number) || 0;
|
|
const invoiceName = await createInvoiceFromSalesOrder({
|
|
salesOrderName: soName,
|
|
paymentGateway: paymentMethod,
|
|
surchargePercent: surchargePct,
|
|
paymentReference: session_id,
|
|
paidAmountCents: totalCents,
|
|
externalOrderId: order_id,
|
|
});
|
|
if (invoiceName) {
|
|
console.log(`[checkout] Compliance invoice ${invoiceName} created for ${order_id}`);
|
|
}
|
|
|
|
// Close the Sales Order so the portal doesn't show "To Deliver"
|
|
// with a pay button. Services don't need delivery — once billed,
|
|
// the SO should be Closed.
|
|
try {
|
|
await callMethod(
|
|
"erpnext.selling.doctype.sales_order.sales_order.update_status",
|
|
{ status: "Closed", name: soName },
|
|
);
|
|
console.log(`[checkout] Closed SO ${soName} (no delivery needed for services)`);
|
|
} catch (closeErr) {
|
|
// Non-fatal — the SO will still work, just shows "To Deliver" in portal
|
|
console.warn(`[checkout] Could not close SO ${soName}:`, closeErr);
|
|
}
|
|
} catch (invErr) {
|
|
console.warn("[checkout] Compliance invoice creation failed (non-blocking):", invErr);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Create ERPNext Sales Invoice + Payment Entry (non-blocking) ──────────
|
|
if (order_type === "canada_crtc") {
|
|
const soName = (order.erpnext_sales_order as string) || null;
|
|
const totalCents = (order.total_cents as number) || 0;
|
|
const surchargePct = (order.payment_method as string) === "card" ? 3
|
|
: (order.payment_method as string) === "klarna" ? 5
|
|
: (order.payment_method as string) === "paypal" ? 3
|
|
: 0;
|
|
if (soName) {
|
|
setImmediate(async () => {
|
|
try {
|
|
const invoiceName = await createInvoiceFromSalesOrder({
|
|
salesOrderName: soName,
|
|
paymentGateway: paymentMethod,
|
|
surchargePercent: surchargePct,
|
|
paymentReference: session_id,
|
|
paidAmountCents: totalCents,
|
|
externalOrderId: order_id,
|
|
});
|
|
if (invoiceName) {
|
|
console.log(`[checkout] Invoice ${invoiceName} created for ${order_id}`);
|
|
}
|
|
} catch (invErr) {
|
|
console.warn("[checkout] Invoice creation failed (non-blocking):", invErr);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Send order confirmation email ──────────────────────────────────────
|
|
// Skip for compliance_batch — sendComplianceIntakeEmail already sent
|
|
// a combined confirmation + intake email above.
|
|
if (order_type === "compliance_batch") {
|
|
console.log(`[checkout] Skipping generic confirmation for ${order_id} — intake email already sent`);
|
|
} else try {
|
|
await sendOrderConfirmationEmail({
|
|
order_id,
|
|
order_type,
|
|
customer_email: (order.customer_email as string) || "",
|
|
customer_name: (order.customer_name as string) || "",
|
|
session_id,
|
|
});
|
|
} catch (emailErr) {
|
|
console.error("[checkout] Confirmation email failed (non-blocking):", emailErr);
|
|
}
|
|
}
|
|
|
|
// ── Stripe Issuing topup — transfer from available payments balance ──────────
|
|
export async function topupIssuingBalance(amountCents: number, orderId: string): Promise<string | null> {
|
|
if (!stripe) return null;
|
|
try {
|
|
const topup = await stripe.topups.create({
|
|
amount: amountCents,
|
|
currency: "usd",
|
|
description: `Fund Issuing for order ${orderId}`,
|
|
metadata: { order_id: orderId },
|
|
// destination_balance: "issuing" — available via raw API, not in SDK types yet
|
|
} as any);
|
|
console.log(`[checkout] Stripe Issuing topup ${topup.id}: $${(amountCents / 100).toFixed(2)} for ${orderId}`);
|
|
return topup.id;
|
|
} catch (err) {
|
|
console.error(`[checkout] Stripe Issuing topup failed for ${orderId}:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ── Advance CRTC order to "Client Selection" after funds become available ────
|
|
export async function advanceToClientSelection(orderId: string): Promise<void> {
|
|
// Get order details
|
|
const { rows } = await pool.query(
|
|
`SELECT order_number, customer_email, customer_name, erpnext_sales_order, payment_method,
|
|
funds_available, amb_annual_price_cents
|
|
FROM canada_crtc_orders WHERE order_number = $1`,
|
|
[orderId],
|
|
);
|
|
if (!rows.length) return;
|
|
const order = rows[0] as Record<string, unknown>;
|
|
if (order.funds_available) return; // already done
|
|
|
|
// Mark funds available
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET funds_available = TRUE, funds_available_at = NOW() WHERE order_number = $1`,
|
|
[orderId],
|
|
);
|
|
|
|
// For Stripe payments: topup Issuing balance with vendor expense amount
|
|
const paymentMethod = (order.payment_method as string) || "card";
|
|
if (["card", "ach", "klarna"].includes(paymentMethod)) {
|
|
// Vendor expenses: BC Registry fee (~$277 USD) + annual mailbox cost
|
|
const bcRegistryEstimate = 27700; // ~C$350 → US$277 at 0.72 rate + buffer
|
|
const mailboxCost = (order.amb_annual_price_cents as number) || 0;
|
|
const topupAmount = bcRegistryEstimate + mailboxCost + 5000; // +$50 buffer
|
|
const topupId = await topupIssuingBalance(topupAmount, orderId);
|
|
if (topupId) {
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET stripe_topup_id = $1 WHERE order_number = $2`,
|
|
[topupId, orderId],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Advance ERPNext workflow to "Client Selection"
|
|
const soName = (order.erpnext_sales_order as string) || null;
|
|
if (soName) {
|
|
try {
|
|
await callMethod("frappe.client.set_value", {
|
|
doctype: "Sales Order",
|
|
name: soName,
|
|
fieldname: "workflow_state",
|
|
value: "Client Selection",
|
|
});
|
|
console.log(`[checkout] Advanced Sales Order ${soName} to Client Selection`);
|
|
} catch (wfErr) {
|
|
console.error(`[checkout] Workflow advance to Client Selection failed:`, wfErr);
|
|
}
|
|
}
|
|
|
|
// Send portal setup email
|
|
try {
|
|
await sendClientSelectionEmail(orderId, order);
|
|
} catch (emailErr) {
|
|
console.error("[checkout] Client selection email failed:", emailErr);
|
|
}
|
|
|
|
console.log(`[checkout] Order ${orderId} advanced to Client Selection`);
|
|
}
|
|
|
|
// ── Send "Your carrier setup is ready" portal email ─────────────────────────
|
|
async function sendClientSelectionEmail(orderId: string, order: Record<string, unknown>): Promise<void> {
|
|
const email = (order.customer_email as string) || "";
|
|
const name = (order.customer_name as string) || "there";
|
|
if (!email) return;
|
|
|
|
// Generate portal token
|
|
const { generatePortalToken, portalUrl } = await import("../middleware/portalAuth.js");
|
|
const token = generatePortalToken(orderId, "canada_crtc", email);
|
|
const DOMAIN = `https://${process.env.DOMAIN || "performancewest.net"}`;
|
|
const setupUrl = `${DOMAIN}/portal/setup?order=${orderId}&token=${token}`;
|
|
|
|
const { sendEmail } = await import("../email.js");
|
|
await sendEmail({
|
|
to: email,
|
|
subject: `Your carrier setup is ready — choose your mailbox and phone number`,
|
|
html: `
|
|
<p>Hi ${name},</p>
|
|
<p>Great news — your order <strong>${orderId}</strong> has been funded and we're ready to begin setup.</p>
|
|
<p>Before we can proceed, we need you to select:</p>
|
|
<ul>
|
|
<li><strong>Your mailbox unit number</strong> at the BC location you chose</li>
|
|
<li><strong>Your Canadian phone number (DID)</strong> from available BC numbers</li>
|
|
</ul>
|
|
<p>Click the button below to make your selections:</p>
|
|
<p style="margin:24px 0"><a href="${setupUrl}" style="display:inline-block;background:#1e3a5f;color:#fff;font-size:15px;font-weight:600;padding:14px 32px;border-radius:8px;text-decoration:none;">Set Up My Carrier</a></p>
|
|
<p>This link expires in 72 hours. If you need a new link, contact us at <a href="${DOMAIN}/contact">our support page</a>.</p>
|
|
<p>Best regards,<br/>Performance West Inc.</p>
|
|
`,
|
|
text: `Hi ${name},\n\nYour order ${orderId} is ready for setup. Visit this link to choose your mailbox and phone number:\n${setupUrl}\n\nPerformance West Inc.`,
|
|
});
|
|
|
|
await pool.query(
|
|
`UPDATE canada_crtc_orders SET client_selection_email_sent_at = NOW() WHERE order_number = $1`,
|
|
[orderId],
|
|
);
|
|
console.log(`[checkout] Sent client selection email to ${email} for ${orderId}`);
|
|
}
|
|
|
|
// ── GET /api/v1/checkout/crypto-available — list available cryptocurrencies ───
|
|
router.get("/api/v1/checkout/crypto-available", async (_req, res) => {
|
|
const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000";
|
|
try {
|
|
const r = await fetch(`${shkeeperUrl}/api/v1/crypto`);
|
|
const data = await r.json() as { crypto?: string[]; crypto_list?: Array<{ name: string; display_name: string }> };
|
|
const DISPLAY_NAMES: Record<string, string> = {
|
|
BTC: "Bitcoin", ETH: "Ethereum", LTC: "Litecoin", DOGE: "Dogecoin",
|
|
BNB: "BNB (BSC)", MATIC: "Polygon", TRX: "Tron",
|
|
"ETH-USDT": "USDT (Ethereum)", "ETH-USDC": "USDC (Ethereum)",
|
|
"TRX-USDT": "USDT (Tron)", "TRX-USDC": "USDC (Tron)",
|
|
"BNB-USDT": "USDT (BSC)", "BNB-USDC": "USDC (BSC)",
|
|
};
|
|
const list = (data.crypto_list || (data.crypto || []).map(c => ({ name: c, display_name: DISPLAY_NAMES[c] || c })));
|
|
// Sort: stablecoins first, then by preference
|
|
const ORDER = ["ETH-USDT","TRX-USDT","ETH-USDC","BNB-USDT","BTC","ETH","LTC","BNB","MATIC","DOGE","TRX"];
|
|
list.sort((a, b) => {
|
|
const ai = ORDER.indexOf(a.name); const bi = ORDER.indexOf(b.name);
|
|
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
});
|
|
res.json({ cryptos: list });
|
|
} catch (err) {
|
|
console.error("[checkout] crypto-available error:", err);
|
|
res.status(502).json({ error: "Crypto gateway unavailable" });
|
|
}
|
|
});
|
|
|
|
// ── POST /api/v1/checkout/crypto-invoice — create invoice for chosen coin ────
|
|
router.post("/api/v1/checkout/crypto-invoice", async (req, res) => {
|
|
const { order_id, order_type, crypto_name, amount } = req.body as {
|
|
order_id?: string; order_type?: string; crypto_name?: string; amount?: string;
|
|
};
|
|
if (!order_id || !crypto_name || !amount) {
|
|
res.status(400).json({ error: "order_id, crypto_name, and amount required" }); return;
|
|
}
|
|
|
|
const shkeeperUrl = process.env.SHKEEPER_URL || "http://127.0.0.1:5000";
|
|
const shkeeperKey = process.env.SHKEEPER_API_KEY || "";
|
|
const DOMAIN = `https://${process.env.DOMAIN || "performancewest.net"}`;
|
|
const callbackUrl = `${DOMAIN}/api/v1/webhooks/shkeeper`;
|
|
|
|
try {
|
|
const invoiceRes = await fetch(`${shkeeperUrl}/api/v1/${crypto_name}/payment_request`, {
|
|
method: "POST",
|
|
headers: { "X-Shkeeper-Api-Key": shkeeperKey, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ external_id: order_id, fiat: "USD", amount, callback_url: callbackUrl }),
|
|
});
|
|
const inv = await invoiceRes.json() as {
|
|
status?: string; id?: number; wallet?: string; amount?: string;
|
|
display_name?: string; exchange_rate?: string; recalculate_after?: number;
|
|
};
|
|
|
|
if (inv.status !== "success" || !inv.wallet) {
|
|
console.error("[checkout] SHKeeper invoice failed:", inv);
|
|
res.status(502).json({ error: "Failed to create invoice for this cryptocurrency. Please try another." }); return;
|
|
}
|
|
|
|
// Store details
|
|
const table = (order_type === "formation" ? "formation_orders"
|
|
: order_type === "bundle" ? "bundle_orders"
|
|
: "canada_crtc_orders");
|
|
await pool.query(
|
|
`UPDATE ${table} SET crypto_details = $1 WHERE order_number = $2`,
|
|
[JSON.stringify({
|
|
crypto_name, display_name: inv.display_name, amount: inv.amount,
|
|
wallet: inv.wallet, exchange_rate: inv.exchange_rate, invoice_id: inv.id,
|
|
total_cents: Math.round(parseFloat(amount) * 100),
|
|
}), order_id],
|
|
);
|
|
|
|
console.log(`[checkout] SHKeeper invoice: ${crypto_name} ${inv.amount} → ${inv.wallet} (order ${order_id})`);
|
|
|
|
res.json({
|
|
crypto_name,
|
|
display_name: inv.display_name,
|
|
amount: inv.amount,
|
|
wallet: inv.wallet,
|
|
exchange_rate: inv.exchange_rate,
|
|
});
|
|
} catch (err) {
|
|
console.error("[checkout] crypto-invoice error:", err);
|
|
res.status(502).json({ error: "Crypto gateway unavailable" });
|
|
}
|
|
});
|
|
|
|
// ── GET /api/v1/checkout/crypto-details — retrieve stored crypto payment info ─
|
|
router.get("/api/v1/checkout/crypto-details", async (req, res) => {
|
|
const order_id = (req.query.order_id as string) || "";
|
|
if (!order_id) { res.status(400).json({ error: "order_id required" }); return; }
|
|
|
|
try {
|
|
// Check all order tables
|
|
for (const table of ["canada_crtc_orders", "formation_orders", "bundle_orders"]) {
|
|
const { rows } = await pool.query(
|
|
`SELECT crypto_details, payment_status FROM ${table} WHERE order_number = $1`,
|
|
[order_id],
|
|
);
|
|
if (rows.length && rows[0].crypto_details) {
|
|
const details = rows[0].crypto_details as Record<string, unknown>;
|
|
res.json({
|
|
crypto: {
|
|
name: details.crypto_name,
|
|
display_name: details.display_name,
|
|
amount: details.amount,
|
|
wallet: details.wallet,
|
|
exchange_rate: details.exchange_rate,
|
|
},
|
|
total_cents: details.total_cents,
|
|
payment_status: rows[0].payment_status,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
res.status(404).json({ error: "Order not found or no crypto payment details" });
|
|
} catch (err: any) {
|
|
console.error("[checkout] crypto-details error:", err);
|
|
res.status(500).json({ error: "Internal error" });
|
|
}
|
|
});
|
|
|
|
// ─── Compliance intake email ────────────────────────────────────────────────
|
|
|
|
async function sendComplianceIntakeEmail(
|
|
orderId: string,
|
|
orderType: string,
|
|
orders: Record<string, unknown>[],
|
|
): Promise<void> {
|
|
if (!orders.length) return;
|
|
|
|
const customerEmail = (orders[0].customer_email as string) || "";
|
|
const customerName = (orders[0].customer_name as string) || "";
|
|
if (!customerEmail) return;
|
|
|
|
const firstName = customerName.split(" ")[0] || customerName;
|
|
let intake: Record<string, unknown> = {};
|
|
if (orders[0].intake_data) {
|
|
try {
|
|
intake = typeof orders[0].intake_data === "string"
|
|
? JSON.parse(orders[0].intake_data as string)
|
|
: orders[0].intake_data as Record<string, unknown>;
|
|
} catch (parseErr) {
|
|
console.error(`[checkout] intake_data JSON parse failed for ${orderId}:`, parseErr);
|
|
}
|
|
}
|
|
const entityName = (intake.entity_name as string) || customerName;
|
|
const frn = (intake.frn as string) || "";
|
|
|
|
// Build service list
|
|
const serviceLines = orders
|
|
.map(o => `<li style="margin:4px 0;font-size:14px;color:#374151;">${o.service_name}</li>`)
|
|
.join("\n");
|
|
|
|
// Check if any 499-A service is included
|
|
const has499 = orders.some(o =>
|
|
((o.service_slug as string) || "").includes("499") ||
|
|
((o.service_name as string) || "").toLowerCase().includes("499"),
|
|
);
|
|
|
|
const SITE_DOMAIN = process.env.DOMAIN ? `https://${process.env.DOMAIN}` : "https://performancewest.net";
|
|
const confirmUrl = `${SITE_DOMAIN}/order/success?action=usac_delegation&order_id=${orderId}`;
|
|
|
|
const usacSection = has499 ? `
|
|
<div style="background:#fef3c7;border:1px solid #fcd34d;border-radius:8px;padding:16px 20px;margin:20px 0;">
|
|
<p style="margin:0 0 8px;font-size:14px;font-weight:700;color:#92400e;">Action Required: USAC E-File Delegation</p>
|
|
<p style="margin:0 0 12px;font-size:13px;color:#78350f;line-height:1.5;">
|
|
To prepare and file your Form 499-A, we need read access to your USAC E-File account.
|
|
Please delegate access to our filing agent:
|
|
</p>
|
|
<ol style="margin:0 0 12px;padding-left:20px;font-size:13px;color:#78350f;line-height:1.7;">
|
|
<li>Log in to <a href="https://forms.universalservice.org" style="color:#1e40af;">USAC E-File</a></li>
|
|
<li>Go to <strong>Account Settings → Delegate Access</strong></li>
|
|
<li>Add delegate email: <strong>filings@performancewest.net</strong></li>
|
|
<li>Grant <strong>read and file</strong> permissions</li>
|
|
</ol>
|
|
<p style="margin:12px 0;text-align:center;">
|
|
<a href="${confirmUrl}" style="display:inline-block;background:#1a2744;color:#ffffff;font-weight:700;padding:12px 28px;border-radius:8px;text-decoration:none;font-size:14px;">
|
|
I've completed the delegation →
|
|
</a>
|
|
</p>
|
|
<p style="margin:8px 0 0;font-size:12px;color:#92400e;text-align:center;">
|
|
Clicking this button notifies our team that delegation is complete so we can begin your filing.
|
|
</p>
|
|
<p style="margin:8px 0 0;font-size:12px;color:#92400e;">
|
|
If you have multiple years of missed filings, each year must be filed separately.
|
|
We will review your history and contact you with pricing for any additional years.
|
|
</p>
|
|
</div>` : "";
|
|
|
|
const { sendEmail } = await import("../email.js");
|
|
await sendEmail({
|
|
to: customerEmail,
|
|
subject: `Next Steps — ${entityName || "Your"} FCC Compliance Order`,
|
|
html: `<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"></head>
|
|
<body style="margin:0;padding:0;background:#eef0f3;font-family:Arial,sans-serif;">
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#eef0f3;padding:20px 0;">
|
|
<tr><td align="center">
|
|
<table width="620" cellpadding="0" cellspacing="0" style="width:620px;max-width:620px;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
|
|
<tr><td style="background:#1a2744;padding:18px 40px 14px;">
|
|
<span style="color:#8fa8d0;font-size:11px;letter-spacing:1.5px;text-transform:uppercase;">Performance West — Compliance Services</span>
|
|
</td></tr>
|
|
<tr><td style="background:#059669;height:3px;font-size:0;"> </td></tr>
|
|
<tr><td style="padding:32px 40px;">
|
|
|
|
<h1 style="margin:0 0 8px;font-size:22px;font-weight:700;color:#111827;">We're Getting Started</h1>
|
|
<p style="margin:0 0 20px;font-size:15px;color:#6b7280;">Hi ${firstName}, thank you for your order. Here's what happens next.</p>
|
|
|
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;margin-bottom:20px;">
|
|
<tr><td style="padding:16px 20px;">
|
|
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;">Order</p>
|
|
<p style="margin:0;font-size:14px;font-weight:600;color:#1a2744;font-family:monospace;">${orderId}</p>
|
|
</td></tr>
|
|
${entityName ? `<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
|
|
<p style="margin:0 0 4px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;">Entity</p>
|
|
<p style="margin:0;font-size:14px;color:#111827;">${entityName}${frn ? ` (FRN: ${frn})` : ""}</p>
|
|
</td></tr>` : ""}
|
|
<tr><td style="border-top:1px solid #e5e7eb;padding:16px 20px;">
|
|
<p style="margin:0 0 8px;font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;">Services Ordered</p>
|
|
<ul style="margin:0;padding-left:18px;">${serviceLines}</ul>
|
|
</td></tr>
|
|
</table>
|
|
|
|
${usacSection}
|
|
|
|
<h2 style="margin:24px 0 8px;font-size:16px;font-weight:700;color:#111827;">What to Expect</h2>
|
|
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">1.</span> We will review your compliance status within 1 business day.</p>
|
|
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">2.</span> If we need any additional information, we will email you.</p>
|
|
<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">3.</span> You will receive a confirmation for each filing as it is completed.</p>
|
|
${has499 ? `<p style="margin:0 0 4px;font-size:14px;color:#374151;"><span style="color:#1e3a5f;font-weight:600;">4.</span> For 499-A filings, complete the USAC delegation above and click the confirmation button.</p>` : ""}
|
|
|
|
<p style="margin:20px 0 0;font-size:13px;color:#9ca3af;">
|
|
Questions? Contact us at
|
|
<a href="mailto:info@performancewest.net" style="color:#1e40af;">info@performancewest.net</a>
|
|
or <a href="tel:+18884110383" style="color:#1e40af;">1-888-411-0383</a>.
|
|
</p>
|
|
|
|
</td></tr>
|
|
<tr><td style="background:#f4f5f7;border-top:1px solid #e8ecf0;padding:16px 40px;text-align:center;">
|
|
<p style="margin:0;font-size:11px;color:#9ca3af;">Performance West Inc. · performancewest.net · 1-888-411-0383</p>
|
|
</td></tr>
|
|
</table>
|
|
</td></tr></table>
|
|
</body></html>`,
|
|
});
|
|
|
|
console.log(`[checkout] Compliance intake email sent to ${customerEmail} for ${orderId}`);
|
|
}
|
|
|
|
export default router;
|