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:
parent
eed5e4a258
commit
e379e2b10f
4 changed files with 366 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue