CRTC: ERPNext as portal source of truth + harden discount expiry + carrier guide PDF

- checkout.ts: generalize ensureCompliancePortalUser -> ensurePortalUser and
  call it in the CRTC post-payment path so PayPal/crypto/webhook-confirmed CRTC
  orders always get an ERPNext Customer + Website User (the single source of
  truth for portal login/password), matching the compliance fix from the
  PayPal incident. Also flip portal_user_created for canada_crtc/formation.
- canada-crtc.ts: enforce discount active+start/expiry windows, global usage
  limit and applies_to scope server-side at checkout (was active-only), so a
  promo like CANADA200 actually stops working after its expiry.
- scripts/generate_canada_carrier_guide_pdf.py: render the public Canadian
  wholesale carrier/vendor guide PDF (reuses the canonical VENDORS list) to
  site/public/guides/canada-carrier-guide.pdf for the CRTC campaign lead magnet.
This commit is contained in:
justin 2026-06-17 23:34:13 -05:00
parent eed5e4a258
commit e379e2b10f
4 changed files with 366 additions and 14 deletions

View file

@ -226,17 +226,34 @@ router.post("/api/v1/canada-crtc/orders", submitLimiter, async (req, res) => {
: company_type === "numbered_tradename" ? TRADE_NAME_SERVICE_FEE
: 0;
// Discount code (applies to service fee only, not gov fees)
// Discount code (applies to service fee only, not gov fees).
// Enforce active + start/expiry windows + global usage limit + scope
// server-side so promotional expiry (e.g. "expires Friday") is honored at
// checkout, not just in the front-end validator. Mirrors GET
// /api/v1/discount/:code in routes/discounts.ts.
let discountCents = 0;
if (discount_code) {
const dcResult = await pool.query("SELECT * FROM discount_codes WHERE code = $1 AND active = TRUE", [discount_code.toUpperCase()]);
if (dcResult.rows.length > 0) {
const dc = dcResult.rows[0];
const discountableAmount = SERVICE_FEE + typeAddon; // discount applies to full service fee
if (dc.discount_type === "percent") {
discountCents = Math.round((discountableAmount * dc.discount_value) / 100);
const now = new Date();
const notStarted = dc.starts_at && new Date(dc.starts_at) > now;
const expired = dc.expires_at && new Date(dc.expires_at) < now;
const usedUp = dc.max_uses !== null && dc.current_uses >= dc.max_uses;
const outOfScope = (() => {
if (!dc.applies_to) return false; // NULL = all services
const allowed = String(dc.applies_to).split(",").map((s: string) => s.trim().toLowerCase());
return !allowed.includes("canada-crtc");
})();
if (notStarted || expired || usedUp || outOfScope) {
console.warn(`[canada-crtc] discount ${dc.code} rejected at checkout`, { notStarted, expired, usedUp, outOfScope });
} else {
discountCents = Math.min(dc.discount_value, discountableAmount);
const discountableAmount = SERVICE_FEE + typeAddon; // discount applies to full service fee
if (dc.discount_type === "percent") {
discountCents = Math.round((discountableAmount * dc.discount_value) / 100);
} else {
discountCents = Math.min(dc.discount_value, discountableAmount);
}
}
}
}

View file

@ -167,19 +167,21 @@ async function findOrCreateCustomer(
}
/**
* Ensure a compliance customer has an ERPNext portal account and persist the
* Ensure a paid customer has an ERPNext portal account and persist the
* `portal_user_created` flag on their order rows.
*
* ERPNext (portal.performancewest.net) is the single customer portal. Normal
* ERPNext (portal.performancewest.net) is the SINGLE source of truth for the
* customer portal login, password, and the customer/order records. Normal
* Stripe checkout provisions the Website User up-front via findOrCreateCustomer,
* but PayPal / crypto / remediation-pipeline orders reach handlePaymentComplete
* without ever creating one leaving those customers unable to log in. This
* runs in that shared post-payment path so EVERY paid compliance order gets a
* portal account regardless of how it was created or paid. Fully idempotent:
* ensureWebsiteUser no-ops if the User already exists, and we only flip the
* flag (which gates the delivery worker's set-password invite) the first time.
* runs in that shared post-payment path so EVERY paid order (compliance,
* canada_crtc, formation) gets an ERPNext Customer + Website User regardless of
* how it was created or paid. Fully idempotent: ensureWebsiteUser no-ops if the
* User already exists, and we only flip the flag (which gates the delivery
* worker's set-password invite) the first time.
*/
async function ensureCompliancePortalUser(
async function ensurePortalUser(
orderId: string,
orderType: string,
rows: Record<string, unknown>[],
@ -241,7 +243,11 @@ async function ensureCompliancePortalUser(
if (portalUserCreated) {
const table = orderType === "compliance_batch" || orderType === "compliance"
? "compliance_orders"
: null;
: orderType === "canada_crtc"
? "canada_crtc_orders"
: orderType === "formation"
? "formation_orders"
: null;
if (table) {
try {
if (orderType === "compliance_batch") {
@ -1825,7 +1831,7 @@ export async function handlePaymentComplete(
// come straight here and would otherwise skip it (this was the cause of
// customers who paid via PayPal being unable to log in). Idempotent.
try {
await ensureCompliancePortalUser(order_id, order_type, updated.rows);
await ensurePortalUser(order_id, order_type, updated.rows);
} catch (portalErr) {
console.error("[checkout] Compliance portal-user provisioning failed (non-fatal):", portalErr);
}
@ -1874,6 +1880,17 @@ export async function handlePaymentComplete(
if (order_type === "canada_crtc") {
const soName = (order.erpnext_sales_order as string) || null;
// Ensure the customer has an ERPNext portal account (the single source of
// truth for login/password). create-session provisions it up-front for
// card/ACH, but PayPal / crypto / webhook-confirmed orders reach here
// directly and would otherwise skip it — the same gap that left PayPal
// compliance customers unable to log in. Idempotent. See ensurePortalUser.
try {
await ensurePortalUser(order_id, order_type, updated.rows);
} catch (portalErr) {
console.error("[checkout] CRTC portal-user provisioning failed (non-fatal):", portalErr);
}
// 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