diff --git a/controllers/admin/advertisements.controller.js b/controllers/admin/advertisements.controller.js index 4b56e50..6829ea1 100644 --- a/controllers/admin/advertisements.controller.js +++ b/controllers/admin/advertisements.controller.js @@ -61,8 +61,6 @@ function deriveStatus(advertisement) { return "active"; } -const ALLOWED_CTA_VARIANTS = ["default", "outline"]; - function normalizeCtas(ctas) { if (!Array.isArray(ctas)) return []; return ctas @@ -71,12 +69,22 @@ function normalizeCtas(ctas) { .map((c, i) => ({ label: c.label.trim(), link: c.link.trim(), - // First CTA defaults to "default" (primary), second to "outline" — but - // an explicit, valid variant from the client always wins. - variant: ALLOWED_CTA_VARIANTS.includes(c.variant) ? c.variant : (i === 0 ? "default" : "outline"), + // Variant is always derived from position — first CTA is "default" + // (primary), second is "outline" — not user-selectable, so any + // client-sent variant is ignored. + variant: i === 0 ? "default" : "outline", })); } +// Hard cap: max 2 badge labels per advertisement (matches MAX_BADGE_LABELS on the frontend) +function normalizeBadgeLabels(labels) { + if (!Array.isArray(labels)) return []; + return labels + .filter((l) => typeof l === "string" && l.trim().length > 0) + .map((l) => l.trim()) + .slice(0, 2); +} + async function applyAdvertisementFields(advertisement, body) { // placement is the only settable "where" — type/format is always derived // from the placement's registry entry, never accepted directly from the body. @@ -103,7 +111,7 @@ async function applyAdvertisementFields(advertisement, body) { advertisement.content_mode = body.content_mode; } - if (body.badge_label !== undefined) advertisement.badge_label = body.badge_label; + if (body.badge_labels !== undefined) advertisement.badge_labels = normalizeBadgeLabels(body.badge_labels); if (body.headline !== undefined) advertisement.headline = body.headline; if (body.description !== undefined) advertisement.description = body.description; if (body.image_url !== undefined) advertisement.image_url = body.image_url; diff --git a/controllers/admin/notification.controller.js b/controllers/admin/notification.controller.js index ab4e4f9..4356f96 100644 --- a/controllers/admin/notification.controller.js +++ b/controllers/admin/notification.controller.js @@ -17,7 +17,7 @@ const mediaToken = require('../../services/mediaToken.service'); const R = require('../../utils/response.util'); const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util'); -const STICKY_LIMIT = 3; +const STICKY_LIMIT = 2; const IMAGE_INCLUDE = { model: mdl_Assets, as: 'image', diff --git a/controllers/admin/notificationBroadcasts.controller.js b/controllers/admin/notificationBroadcasts.controller.js index d22548d..4794cfa 100644 --- a/controllers/admin/notificationBroadcasts.controller.js +++ b/controllers/admin/notificationBroadcasts.controller.js @@ -66,7 +66,7 @@ async function countActiveSticky(excludeId = null) { }); } -const MAX_ACTIVE_STICKY = 3; +const MAX_ACTIVE_STICKY = 2; const ACTIVE_STICKY_CAP_MESSAGE = `Maximum of ${MAX_ACTIVE_STICKY} active sticky alerts right now — this stays in Draft until one ends or is archived.`; async function validateImageAssetId(image_asset_id) { @@ -97,7 +97,7 @@ async function propagateNotificationVisibility(where, { show_in_sticky, show_in_ async function applyBroadcastFields(broadcast, body) { if (body.title !== undefined) broadcast.title = body.title; - if (body.message !== undefined) broadcast.message = body.message; + if (body.message !== undefined) broadcast.message = body.message || null; if (body.link_url !== undefined) broadcast.link_url = body.link_url?.trim() || null; if (body.link_label !== undefined) broadcast.link_label = body.link_label?.trim() || null; if (body.color !== undefined) broadcast.color = body.color || 'indigo'; @@ -254,7 +254,6 @@ exports.createBroadcast = async (req, res) => { } = req.body; if (!title) return R.error(res, "title is required.", 400); - if (!message) return R.error(res, "message is required.", 400); if (!target_type) return R.error(res, "target_type is required.", 400); if (!ALLOWED_TARGET_TYPES.includes(target_type)) return R.error(res, `Invalid target_type. Must be one of: ${ALLOWED_TARGET_TYPES.join(", ")}`, 400); if (SCOPED_TARGET_TYPES.includes(target_type) && !target_id) return R.error(res, "target_id is required for this target_type.", 400); @@ -265,6 +264,12 @@ exports.createBroadcast = async (req, res) => { if (!showSticky && !showNotifs) { return R.error(res, "At least one of show_in_sticky or show_in_notifications must be enabled.", 400); } + if (showSticky && showNotifs) { + return R.error(res, "Choose only one: Sticky or Notifications.", 400); + } + if (showNotifs && !message) { + return R.error(res, "message is required for Notifications alerts.", 400); + } if (start_date && end_date && new Date(start_date) > new Date(end_date)) { return R.error(res, "Start date must be before end date.", 400); @@ -278,7 +283,7 @@ exports.createBroadcast = async (req, res) => { try { const broadcast = await NotificationBroadcast.build({ title, - message, + message: message || null, link_url: link_url?.trim() || null, link_label: link_label?.trim() || null, color: color || 'indigo', @@ -328,6 +333,16 @@ exports.updateBroadcast = async (req, res) => { err.status = 400; throw err; } + if (broadcast.show_in_sticky && broadcast.show_in_notifications) { + const err = new Error("Choose only one: Sticky or Notifications."); + err.status = 400; + throw err; + } + if (broadcast.show_in_notifications && !broadcast.message) { + const err = new Error("message is required for Notifications alerts."); + err.status = 400; + throw err; + } // Editing a live broadcast to newly flip on show_in_sticky is the same // "activate a sticky slot" action as sendBroadcast — must respect the @@ -352,7 +367,9 @@ exports.updateBroadcast = async (req, res) => { if (broadcast.status === 'sent') { const propagated = { title: broadcast.title, - message: broadcast.message, + // admin_notifications/user_notifications.message stays NOT NULL — + // sticky-mode broadcasts have a null message here, so fall back to "". + message: broadcast.message || "", color: broadcast.color, image_asset_id: broadcast.image_asset_id, show_in_sticky: broadcast.show_in_sticky, @@ -419,7 +436,7 @@ exports.sendBroadcast = async (req, res) => { const baseNotify = NOTIFICATION_REGISTRY.broadcast.build({ title: broadcast.title, - message: broadcast.message, + message: broadcast.message || "", targetType, targetId, linkUrl: broadcast.link_url, @@ -458,7 +475,7 @@ exports.sendBroadcast = async (req, res) => { user_id, ...(targetType === 'task_list' ? NOTIFICATION_REGISTRY.broadcast.build({ - title: broadcast.title, message: broadcast.message, targetType, targetId, + title: broadcast.title, message: broadcast.message || "", targetType, targetId, groupId: groupByUser[user_id] ?? null, linkUrl: broadcast.link_url, linkLabel: broadcast.link_label, diff --git a/controllers/client/course_purchases.controller.js b/controllers/client/course_purchases.controller.js index fd52fa9..0e220d0 100644 --- a/controllers/client/course_purchases.controller.js +++ b/controllers/client/course_purchases.controller.js @@ -104,6 +104,19 @@ exports.captureCourseOrder = async (req, res) => { const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; + // PayPal can return an HTTP 2xx from the capture endpoint even when the + // charge itself was declined or held for review (e.g. capture.status + // "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual + // status field must be checked explicitly before granting any access. + const captureStatus = capture?.status ?? captureData.status; + if (captureStatus !== 'COMPLETED') { + await purchase.update({ + status: 'failed', + provider_payload: { ...purchase.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' }, + }); + return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402); + } + await purchase.update({ status: 'completed', paid_at: new Date(), diff --git a/controllers/client/notification.controller.js b/controllers/client/notification.controller.js index 7cd1afa..a1d8492 100644 --- a/controllers/client/notification.controller.js +++ b/controllers/client/notification.controller.js @@ -17,7 +17,7 @@ const mediaToken = require('../../services/mediaToken.service'); const R = require('../../utils/response.util'); const { notInFutureOrExpired } = require('../../utils/notificationVisibility.util'); -const STICKY_LIMIT = 3; +const STICKY_LIMIT = 2; const IMAGE_INCLUDE = { model: mdl_Assets, as: 'image', diff --git a/controllers/client/tiers.controller.js b/controllers/client/tiers.controller.js index 7c8786f..5d39bf7 100644 --- a/controllers/client/tiers.controller.js +++ b/controllers/client/tiers.controller.js @@ -300,6 +300,19 @@ exports.captureOrder = async (req, res) => { const capture = captureData.purchase_units?.[0]?.payments?.captures?.[0]; + // PayPal can return an HTTP 2xx from the capture endpoint even when the + // charge itself was declined or held for review (e.g. capture.status + // "DECLINED"/"PENDING") — axios only throws on non-2xx, so the actual + // status field must be checked explicitly before granting any access. + const captureStatus = capture?.status ?? captureData.status; + if (captureStatus !== 'COMPLETED') { + await payment.update({ + status: 'failed', + provider_payload: { ...payment.provider_payload, capture: captureData, failed_reason: captureStatus ?? 'unknown' }, + }); + return R.error(res, `Payment was not completed by PayPal (status: ${captureStatus ?? 'unknown'}).`, 402); + } + // Repurchasing a plan under a tier already held active extends the // existing grant's expires_at by the new plan's duration, rather than // being blocked/refunded — the original plan_id is kept (whichever plan diff --git a/cron/client.cron.js b/cron/client.cron.js index 38137bf..0e8d41d 100644 --- a/cron/client.cron.js +++ b/cron/client.cron.js @@ -18,6 +18,7 @@ * - expireUserTiers (cron/jobs/expire_user_tiers.cron.js) * - taskDueSoon (cron/jobs/task_due_soon.cron.js) * - expireAdvertisements (cron/jobs/expire_advertisements.cron.js) — plain + * - failStalePayments (cron/jobs/fail_stale_payments.cron.js) — plain * * Author: Kenneth Obsequio (@lash0000) * Date Created: Jun. 17, 2026 @@ -28,6 +29,7 @@ const issueCertificates = require('./jobs/issue_certificates.cron'); const expireUserTiers = require('./jobs/expire_user_tiers.cron'); const taskDueSoon = require('./jobs/task_due_soon.cron'); const expireAdvertisements = require('./jobs/expire_advertisements.cron'); +const failStalePayments = require('./jobs/fail_stale_payments.cron'); const { startSettingsBackedJobs } = require('./cronRegistry.util'); // ─── Registry — add future client-side cron jobs here ──────────────────────── @@ -41,6 +43,7 @@ const settingsBackedJobs = [ // Plain hardcoded-schedule jobs (not tied to any notification setting). const plainJobs = [ expireAdvertisements, + failStalePayments, ]; // ─── Boot all registered client-side jobs ───────────────────────────────────── diff --git a/cron/jobs/fail_stale_payments.cron.js b/cron/jobs/fail_stale_payments.cron.js new file mode 100644 index 0000000..371f8a0 --- /dev/null +++ b/cron/jobs/fail_stale_payments.cron.js @@ -0,0 +1,87 @@ +/*********************************************************************************************************************************************************************** + * File Name : fail_stale_payments.cron.js + * Type : Cron Job + * Description : Marks abandoned checkout attempts as 'failed' instead of + * leaving them stuck on 'pending' forever. + * + * A payment/purchase row is created as 'pending' the moment + * PayPal's create-order call succeeds (createOrder / createCourseOrder), + * before the buyer ever reaches PayPal's approval page. If PayPal's + * own hosted checkout then fails to load ("Things don't appear to + * be working at the moment") or the buyer just abandons the tab, + * the browser never gets redirected back to our return_url/cancel_url — + * so captureOrder/cancelOrder is never called, and the row sits as + * 'pending' indefinitely even though no money ever moved. + * + * This job sweeps 'pending' rows older than STALE_MINUTES and + * marks them 'failed', so payment history correctly reflects + * that nothing was charged, instead of silently doing nothing. + * + * Schedule : Every 10 minutes ("*\/10 * * * *"). Registered by cron/client.cron.js. + * + * Author: Kenneth Obsequio (@lash0000) + * Date Created: Aug. 3, 2026 + ***********************************************************************************************************************************************************************/ +'use strict'; + +const { Op } = require('sequelize'); +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); + +const STALE_MINUTES = 30; + +async function run() { + const cutoff = new Date(Date.now() - STALE_MINUTES * 60_000); + + try { + const stalePayments = await mdl_Payments.findAll({ + where: { status: 'pending', createdAt: { [Op.lt]: cutoff } }, + }); + + for (const payment of stalePayments) { + await payment.update({ + status: 'failed', + provider_payload: { + ...payment.provider_payload, + failed_reason: 'abandoned_checkout', + marked_failed_at: new Date().toISOString(), + }, + }); + } + + if (stalePayments.length) { + console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePayments.length} abandoned tier payment(s) as failed.`); + } + } catch (err) { + console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping payments:', err); + } + + try { + const stalePurchases = await mdl_CoursePurchase.findAll({ + where: { status: 'pending', createdAt: { [Op.lt]: cutoff } }, + }); + + for (const purchase of stalePurchases) { + await purchase.update({ + status: 'failed', + provider_payload: { + ...purchase.provider_payload, + failed_reason: 'abandoned_checkout', + marked_failed_at: new Date().toISOString(), + }, + }); + } + + if (stalePurchases.length) { + console.log(`[CRON][FAIL STALE PAYMENTS] Marked ${stalePurchases.length} abandoned course purchase(s) as failed.`); + } + } catch (err) { + console.error('[CRON][FAIL STALE PAYMENTS] Failed sweeping course purchases:', err); + } +} + +module.exports = { + name: 'failStalePayments', + schedule: '*/10 * * * *', + run, +}; diff --git a/database/migrations/20270101000085-convert-advertisements-badge-label-to-array.js b/database/migrations/20270101000085-convert-advertisements-badge-label-to-array.js new file mode 100644 index 0000000..e9b2cdd --- /dev/null +++ b/database/migrations/20270101000085-convert-advertisements-badge-label-to-array.js @@ -0,0 +1,58 @@ +'use strict'; + +// badge_label was a single STRING(100). The Type step redesign lets admins +// attach up to 2 badges (outline chips) to a "Text with Image" ad, so the +// column becomes a JSONB string array (renamed badge_labels to match the +// plural shape, mirroring the existing `ctas` JSONB column). Any existing +// scalar value is wrapped into a single-element array; null/empty becomes []. +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE advertisements + ALTER COLUMN badge_label TYPE JSONB + USING ( + CASE + WHEN badge_label IS NULL OR badge_label = '' THEN '[]'::jsonb + ELSE jsonb_build_array(badge_label) + END + ) + `); + + await queryInterface.sequelize.query(` + ALTER TABLE advertisements + ALTER COLUMN badge_label SET DEFAULT '[]'::jsonb + `); + + await queryInterface.sequelize.query(` + ALTER TABLE advertisements + ALTER COLUMN badge_label SET NOT NULL + `); + + await queryInterface.renameColumn('advertisements', 'badge_label', 'badge_labels'); + }, + + async down(queryInterface) { + await queryInterface.renameColumn('advertisements', 'badge_labels', 'badge_label'); + + await queryInterface.sequelize.query(` + ALTER TABLE advertisements + ALTER COLUMN badge_label DROP NOT NULL + `); + + await queryInterface.sequelize.query(` + ALTER TABLE advertisements + ALTER COLUMN badge_label TYPE STRING(100) + USING ( + CASE + WHEN jsonb_array_length(badge_label) = 0 THEN NULL + ELSE badge_label->>0 + END + ) + `); + + await queryInterface.sequelize.query(` + ALTER TABLE advertisements + ALTER COLUMN badge_label DROP DEFAULT + `); + }, +}; diff --git a/database/migrations/20270101000086-remove-message-from-notification-broadcasts.js b/database/migrations/20270101000086-remove-message-from-notification-broadcasts.js new file mode 100644 index 0000000..c06a668 --- /dev/null +++ b/database/migrations/20270101000086-remove-message-from-notification-broadcasts.js @@ -0,0 +1,18 @@ +'use strict'; + +// Alerts are title-only now — the composer no longer collects a body message +// (see AddNotificationBroadcast.jsx). Only this table's column goes; the +// per-recipient admin_notifications/user_notifications.message columns stay +// (shared by ~20 unrelated notification types via NOTIFICATION_REGISTRY). +module.exports = { + async up(queryInterface) { + await queryInterface.removeColumn('notification_broadcasts', 'message'); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.addColumn('notification_broadcasts', 'message', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, +}; diff --git a/database/migrations/20270101000087-add-message-back-to-notification-broadcasts.js b/database/migrations/20270101000087-add-message-back-to-notification-broadcasts.js new file mode 100644 index 0000000..147777c --- /dev/null +++ b/database/migrations/20270101000087-add-message-back-to-notification-broadcasts.js @@ -0,0 +1,17 @@ +'use strict'; + +// Notifications-type alerts need a body again; Sticky-type alerts stay +// title-only. Nullable this time (sticky rows never populate it) — reverses +// 20270101000086, which dropped this same column as NOT NULL. +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('notification_broadcasts', 'message', { + type: Sequelize.TEXT, + allowNull: true, + }); + }, + + async down(queryInterface) { + await queryInterface.removeColumn('notification_broadcasts', 'message'); + }, +}; diff --git a/models/advertisements/advertisements.attributes.js b/models/advertisements/advertisements.attributes.js index 98966d0..0567ae7 100644 --- a/models/advertisements/advertisements.attributes.js +++ b/models/advertisements/advertisements.attributes.js @@ -33,6 +33,10 @@ const jsonbSchemas = { variant: "string", // "default" | "outline" }, }, + badge_labels: { + type: "array", + itemShape: "string", + }, }; // ─── Computed attributes ────────────────────────────────────────────────────── diff --git a/models/advertisements/advertisements.mdl.js b/models/advertisements/advertisements.mdl.js index 806c8d0..f8995ee 100644 --- a/models/advertisements/advertisements.mdl.js +++ b/models/advertisements/advertisements.mdl.js @@ -37,7 +37,8 @@ const Advertisement = sequelize.define("Advertisement", { // the image — an explicit admin choice made in the creation wizard, decoupled // from placement/format. content_mode: { type: DataTypes.ENUM("image", "content"), allowNull: false, defaultValue: "image", label: "Content Mode", order: 3.5 }, - badge_label: { type: DataTypes.STRING(100), label: "Badge Label", order: 4 }, + // Up to 2 outline-badge chips shown alongside the headline — see MAX_BADGE_LABELS. + badge_labels: { type: DataTypes.JSONB, allowNull: false, defaultValue: [], label: "Badge Labels", order: 4 }, headline: { type: DataTypes.STRING(255), label: "Headline", order: 5 }, description: { type: DataTypes.TEXT, label: "Description", order: 6 }, diff --git a/models/notifications/notification_broadcast.mdl.js b/models/notifications/notification_broadcast.mdl.js index df54fb6..3e22142 100644 --- a/models/notifications/notification_broadcast.mdl.js +++ b/models/notifications/notification_broadcast.mdl.js @@ -12,7 +12,9 @@ const NotificationBroadcast = sequelize.define("NotificationBroadcast", { // ─── Content ────────────────────────────────────────────────────────────── title: { type: DataTypes.STRING(255), allowNull: false, label: "Title", order: 1 }, - message: { type: DataTypes.TEXT, allowNull: false, label: "Message", order: 2 }, + // Only used by Notifications-type alerts (show_in_notifications) — Sticky + // alerts (show_in_sticky) are title-only and leave this null. + message: { type: DataTypes.TEXT, allowNull: true, label: "Message", order: 1.5 }, // When set, the client's "view full content" dialog shows an "Open Link" // action pointing here. When null, that dialog is plain text info only. link_url: { type: DataTypes.STRING(2048), allowNull: true, label: "Link URL", order: 2.2 }, diff --git a/tests/controllers/tiers.controller.capture.test.js b/tests/controllers/tiers.controller.capture.test.js new file mode 100644 index 0000000..a8accf3 --- /dev/null +++ b/tests/controllers/tiers.controller.capture.test.js @@ -0,0 +1,116 @@ +'use strict'; + +// ── Regression test for the "declined payment still granted access" bug ────── +// PayPal's v2 capture-order endpoint can respond with HTTP 2xx even when the +// charge itself was declined or held for review — the failure only shows up +// in the response body's status field (e.g. capture.status: "DECLINED" or +// "PENDING"), not as a thrown HTTP error. Before the fix, captureOrder() only +// guarded against a *thrown* error from the provider and unconditionally +// granted the tier on any non-throwing response. These tests pin the fix: +// a non-COMPLETED capture must mark the payment failed and must NOT create +// or extend a user_tiers row. + +jest.mock('../../models/tiers/tier_categories.mdl', () => ({})); +jest.mock('../../models/tiers/tier_plans.mdl', () => ({ findOne: jest.fn() })); +jest.mock('../../models/tiers/user_tiers.mdl', () => ({ findOne: jest.fn(), create: jest.fn() })); +jest.mock('../../models/tiers/payments.mdl', () => ({ findOne: jest.fn() })); +jest.mock('../../models/system_badges/system_badges.mdl', () => ({})); +jest.mock('../../models/assets/assets.mdl', () => ({})); +jest.mock('../../models/notifications/user_notification.mdl', () => ({ create: jest.fn() })); +jest.mock('../../services/achievements.service', () => ({ onTierActivated: jest.fn() })); +jest.mock('../../models/courses/courses.mdl', () => ({ Course: {} })); +jest.mock('../../services/payment.service', () => ({ captureOrder: jest.fn() })); +jest.mock('../../data/notifications.data', () => ({ NOTIFICATION_REGISTRY: {} })); +jest.mock('../../models/tiers/tier.associations', () => ({})); + +const mdl_UserTiers = require('../../models/tiers/user_tiers.mdl'); +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const paymentSvc = require('../../services/payment.service'); +const controller = require('../../controllers/client/tiers.controller'); + +function mockRes() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makePayment(overrides = {}) { + return { + payment_id: 1, + plan_id: 10, + provider: 'paypal', + provider_payload: { order_id: 'ORDER-1' }, + plan: { plan_id: 10, tier: 'premium', duration_days: 30, is_active: true }, + update: jest.fn().mockResolvedValue(true), + ...overrides, + }; +} + +beforeEach(() => jest.clearAllMocks()); + +describe('captureOrder() — declined/incomplete captures must not grant access', () => { + + test('capture.status "DECLINED" marks the payment failed and creates no tier', async () => { + const payment = makePayment(); + mdl_Payments.findOne.mockResolvedValue(payment); + paymentSvc.captureOrder.mockResolvedValue({ + status: 'COMPLETED', // outer order status can still say COMPLETED + purchase_units: [{ payments: { captures: [{ id: 'CAP-1', status: 'DECLINED' }] } }], + }); + + const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } }; + const res = mockRes(); + + await controller.captureOrder(req, res); + + expect(mdl_UserTiers.create).not.toHaveBeenCalled(); + expect(payment.update).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ); + expect(res.status).toHaveBeenCalledWith(402); + }); + + test('capture.status "PENDING" (e.g. eCheck review) also withholds access', async () => { + const payment = makePayment(); + mdl_Payments.findOne.mockResolvedValue(payment); + paymentSvc.captureOrder.mockResolvedValue({ + status: 'COMPLETED', + purchase_units: [{ payments: { captures: [{ id: 'CAP-2', status: 'PENDING' }] } }], + }); + + const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } }; + const res = mockRes(); + + await controller.captureOrder(req, res); + + expect(mdl_UserTiers.create).not.toHaveBeenCalled(); + expect(payment.update).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }) + ); + expect(res.status).toHaveBeenCalledWith(402); + }); + + test('capture.status "COMPLETED" still grants the tier (control case)', async () => { + const payment = makePayment(); + mdl_Payments.findOne.mockResolvedValue(payment); + mdl_UserTiers.findOne.mockResolvedValue(null); // no existing active tier + mdl_UserTiers.create.mockResolvedValue({ tier_id: 99, tier: 'premium', expires_at: new Date() }); + paymentSvc.captureOrder.mockResolvedValue({ + status: 'COMPLETED', + payer: { payer_id: 'PAYER-1' }, + purchase_units: [{ payments: { captures: [{ id: 'CAP-3', status: 'COMPLETED' }] } }], + }); + + const req = { user: { user_id: 1 }, body: { order_id: 'ORDER-1' } }; + const res = mockRes(); + + await controller.captureOrder(req, res); + + expect(mdl_UserTiers.create).toHaveBeenCalled(); + expect(payment.update).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ); + expect(res.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/tests/cron/fail_stale_payments.test.js b/tests/cron/fail_stale_payments.test.js new file mode 100644 index 0000000..593944c --- /dev/null +++ b/tests/cron/fail_stale_payments.test.js @@ -0,0 +1,40 @@ +'use strict'; + +jest.mock('../../models/tiers/payments.mdl', () => ({ findAll: jest.fn() })); +jest.mock('../../models/courses/course_purchases.mdl', () => ({ findAll: jest.fn() })); + +const mdl_Payments = require('../../models/tiers/payments.mdl'); +const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl'); +const job = require('../../cron/jobs/fail_stale_payments.cron'); + +beforeEach(() => jest.clearAllMocks()); + +test('marks stale pending payments and purchases as failed', async () => { + const payment = { provider_payload: { order_id: 'O-1' }, update: jest.fn().mockResolvedValue(true) }; + const purchase = { provider_payload: { order_id: 'O-2' }, update: jest.fn().mockResolvedValue(true) }; + + mdl_Payments.findAll.mockResolvedValue([payment]); + mdl_CoursePurchase.findAll.mockResolvedValue([purchase]); + + await job.run(); + + expect(payment.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + provider_payload: expect.objectContaining({ failed_reason: 'abandoned_checkout' }), + }) + ); + expect(purchase.update).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failed', + provider_payload: expect.objectContaining({ failed_reason: 'abandoned_checkout' }), + }) + ); +}); + +test('does nothing when there are no stale rows', async () => { + mdl_Payments.findAll.mockResolvedValue([]); + mdl_CoursePurchase.findAll.mockResolvedValue([]); + + await expect(job.run()).resolves.not.toThrow(); +});