feat(admin): add offering pricing and payment/refund UI

- admin-offerings-routes.js: PATCH /api/admin/offerings/:id (allowlisted fields), GET
  /api/admin/registrations/:id/payments, POST /api/admin/payments/:id/refund
- frontend: Pricing card on offering detail, Payments card (transaction history + record-refund
  action) on registration detail — admin/owner-gated, reusing existing admin UI conventions
This commit is contained in:
2026-08-27 09:05:36 -05:00
parent 16622bd9ab
commit f0dde9b84a
3 changed files with 91 additions and 1 deletions
+26 -1
View File
@@ -9,6 +9,7 @@
const offerings = require('./offerings');
const notifications = require('./notifications');
const commerce = require('./commerce');
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) {
@@ -17,7 +18,8 @@ function errStatus(code) {
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION'
|| code === 'AGREEMENTS_INCOMPLETE') return 409;
|| code === 'AGREEMENTS_INCOMPLETE' || code === 'NO_PRICE_CONFIGURED') return 409;
if (code === 'PROVIDER_NOT_CONFIGURED') return 503;
return 500;
}
@@ -31,6 +33,7 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
};
function handleError(res, e) {
if (e.code) return res.status(errStatus(e.code)).json({ error: e.code, message: e.message });
@@ -116,6 +119,15 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
if (!offering) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(offering);
});
// Phase 9 §28 — the one route Chase actually uses to fill in dates/venue/capacity/price once
// those real business decisions are made, on an offering that already exists as a draft.
admin.patch('/api/admin/offerings/:id', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.updateOffering(getPool(), req.params.id, req.body || {});
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'offering_update', resourceType: 'offering', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { fields: Object.keys(req.body || {}) } });
res.json(offering);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status);
@@ -171,6 +183,19 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
const rows = await notifications.listForRegistration(getPool(), req.params.id);
res.json(rows.map(notifications.toAdminView));
});
// Phase 9 §18/§19 — payment visibility. The row itself never contains card data (only
// amount/currency/status/timestamps/the provider's own opaque reference), so it's safe to
// return whole — admin/owner only, same as every other registration-detail surface.
admin.get('/api/admin/registrations/:id/payments', requireAdminOrOwner, async (req, res) => {
res.json(await commerce.listTransactionsForRegistration(getPool(), req.params.id));
});
admin.post('/api/admin/payments/:id/refund', requireAdminOrOwner, async (req, res) => {
try {
const transaction = await commerce.recordManualRefund(getPool(), req.params.id, req.body?.status || 'refunded');
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'refund_recorded', resourceType: 'payment_transaction', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { status: transaction.status } });
res.json(transaction);
} catch (e) { handleError(res, e); }
});
admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
try {
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);