Files
starr-philproperties/apps/api/controllers/client/course_purchases.controller.js
2026-08-31 17:57:29 +08:00

189 lines
8.0 KiB
JavaScript

'use strict';
const mdl_CoursePurchase = require('../../models/courses/course_purchases.mdl');
const mdl_Product = require('../../models/courses/products.mdl');
const paymentSvc = require('../../services/payment.service');
const R = require('../../utils/response.util');
const { resolvePurchasable, checkoutPath } = require('../../utils/purchasable.util');
// ─── CREATE ORDER ─────────────────────────────────────────────────────────────
// Despite the "course" naming (historical — this predates Units/Lessons being
// individually purchasable), this endpoint and table are generic: a Product's
// purchasable_type/purchasable_id drives everything below.
exports.createCourseOrder = async (req, res) => {
try {
const { product_id } = req.body;
if (!product_id) return R.error(res, 'product_id is required.', 400);
const product = await mdl_Product.findOne({ where: { id: product_id, is_active: true } });
if (!product) return R.error(res, 'Product not found or inactive.', 404);
const existing = await mdl_CoursePurchase.findOne({
where: { user_id: req.user.user_id, product_id, status: 'completed' },
});
if (existing) {
const stillActive = !existing.expires_at || new Date(existing.expires_at) > new Date();
if (stillActive) return R.error(res, `You already have active access to this ${product.purchasable_type}.`, 409);
}
const target = await resolvePurchasable(product.purchasable_type, product.purchasable_id);
if (!target) return R.error(res, 'Purchasable content not found.', 404);
const path = checkoutPath(product.purchasable_type, target);
const ppOrder = await paymentSvc.createOrder('paypal', {
amount: Number(product.price).toFixed(2),
currency: product.currency,
referenceId: `user_${req.user.user_id}_product_${product_id}`,
returnUrl: `${process.env.FRONTEND_URL}${path}`,
cancelUrl: `${process.env.FRONTEND_URL}${path}?cancelled=true`,
});
const approvalUrl = ppOrder.links?.find((l) => l.rel === 'approve')?.href ?? null;
const expiresAt = product.access_days
? new Date(Date.now() + product.access_days * 86400000)
: null;
const purchase = await mdl_CoursePurchase.create({
user_id: req.user.user_id,
product_id,
amount: product.price,
currency: product.currency,
status: 'pending',
provider: 'paypal',
expires_at: expiresAt,
provider_payload: { order_id: ppOrder.id, approval_url: approvalUrl },
});
return R.success(res, 'Order created.', {
purchase_id: purchase.id,
order_id: ppOrder.id,
approval_url: approvalUrl,
amount: product.price,
currency: product.currency,
}, 201);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CREATE ORDER]', err);
return R.error(res, 'Could not create order.', 500);
}
};
// ─── CAPTURE ORDER ────────────────────────────────────────────────────────────
exports.captureCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
// Matched on provider_payload.order_id, not just "most recent pending" —
// see tiers.controller.js#captureOrder for why picking by recency alone
// can miss an older order that PayPal legitimately approved.
const pendingPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'pending' },
include: [{ model: mdl_Product, as: 'product' }],
order: [['createdAt', 'DESC']],
});
const purchase = pendingPurchases.find((p) => p.provider_payload?.order_id === order_id);
if (!purchase)
return R.error(res, 'Pending purchase not found.', 404);
let captureData;
try {
captureData = await paymentSvc.captureOrder(purchase.provider, order_id);
} catch (ppErr) {
await purchase.update({
status: 'failed',
provider_payload: { ...purchase.provider_payload, error: ppErr?.response?.data ?? {} },
});
return R.error(res, 'Payment capture failed.', 402);
}
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(),
provider_payload: {
...purchase.provider_payload,
capture_id: capture?.id,
payer_id: captureData.payer?.payer_id,
capture: captureData,
},
});
const target = await resolvePurchasable(purchase.product.purchasable_type, purchase.product.purchasable_id);
return R.success(res, 'Payment successful. Access granted.', {
purchase_id: purchase.id,
expires_at: purchase.expires_at,
purchasable_type: purchase.product.purchasable_type,
purchasable_id: purchase.product.purchasable_id,
target_uuid: target?.uuid ?? null,
});
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CAPTURE]', err);
return R.error(res, 'Could not capture order.', 500);
}
};
// ─── CANCEL ORDER ─────────────────────────────────────────────────────────────
exports.cancelCourseOrder = async (req, res) => {
try {
const { order_id } = req.body;
if (!order_id) return R.error(res, 'order_id is required.', 400);
// See captureCourseOrder above for why this matches on order_id instead of recency.
const pendingPurchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id, status: 'pending' },
order: [['createdAt', 'DESC']],
});
const purchase = pendingPurchases.find((p) => p.provider_payload?.order_id === order_id);
if (!purchase)
return R.error(res, 'Pending purchase not found.', 404);
await purchase.update({
status: 'cancelled',
provider_payload: { ...purchase.provider_payload, cancelled_at: new Date().toISOString() },
});
return R.success(res, 'Purchase cancelled.');
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][CANCEL]', err);
return R.error(res, 'Could not cancel purchase.', 500);
}
};
// ─── MY PURCHASES ─────────────────────────────────────────────────────────────
exports.getMyPurchases = async (req, res) => {
try {
const purchases = await mdl_CoursePurchase.findAll({
where: { user_id: req.user.user_id },
include: [{ model: mdl_Product, as: 'product', attributes: ['id', 'name', 'purchasable_type', 'purchasable_id', 'access_days'] }],
attributes: { exclude: ['provider_payload'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Purchases retrieved.', purchases);
} catch (err) {
console.error('[CLIENT][COURSE PURCHASE][GET MINE]', err);
return R.error(res, 'Could not retrieve purchases.', 500);
}
};