diff --git a/frontend/src/admin/OfferingsView.jsx b/frontend/src/admin/OfferingsView.jsx index d2cb4ef..744304a 100644 --- a/frontend/src/admin/OfferingsView.jsx +++ b/frontend/src/admin/OfferingsView.jsx @@ -188,6 +188,12 @@ function OfferingDetail({ offeringId, canAuthor, onBack }) { )} +
+

Pricing

+

{offering.price_amount === null ? Free — no checkout required. : <>{(offering.price_amount / 100).toFixed(2)} {offering.price_currency.toUpperCase()}}

+ {canAuthor && } +
+

Launch readiness

{!readiness ?

Loading…

: ( @@ -291,6 +297,36 @@ function CreateRegistrationForm({ offeringId, onCreated }) { ) } +function SetPriceForm({ offeringId, current, onSaved }) { + const [amount, setAmount] = useState(current.price_amount === null ? '' : (current.price_amount / 100).toFixed(2)) + const [currency, setCurrency] = useState(current.price_currency || 'usd') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + const submit = async (e) => { + e.preventDefault() + setBusy(true); setError(null) + try { + await adminApi.updateOffering(offeringId, { + priceAmount: amount === '' ? null : Math.round(Number(amount) * 100), + priceCurrency: currency, + }) + onSaved() + } catch (e) { setError(e.message) } finally { setBusy(false) } + } + + return ( +
+ {error &&
{error}
} +
+ setAmount(e.target.value)} />
+
+ setCurrency(e.target.value.toLowerCase())} style={{ width: '5rem' }} />
+ +
+ ) +} + function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) { const [agreementId, setAgreementId] = useState('') const [busy, setBusy] = useState(false) @@ -321,6 +357,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) { const [reg, setReg] = useState(null) const [agreementStatus, setAgreementStatus] = useState(null) const [notifs, setNotifs] = useState(null) + const [payments, setPayments] = useState(null) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) const [busy, setBusy] = useState(false) @@ -335,6 +372,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) { adminApi.registration(registrationId).then(setReg).catch((e) => setError(e.message)) adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {}) adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {}) + adminApi.registrationPayments(registrationId).then(setPayments).catch(() => {}) }, [registrationId]) useEffect(reload, [reload]) @@ -387,6 +425,30 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) { )}
+
+

Payments

+ {!payments ?

Loading…

: payments.length === 0 ?

No checkout attempted — this offering may be free, or checkout hasn't started yet.

: ( + + + + {payments.map((p) => ( + + + + + + + + ))} + +
AmountStatusProviderPaid
{(p.amount / 100).toFixed(2)} {p.currency.toUpperCase()}{p.status.replace('_', ' ')}{p.provider}{p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'} + {canAuthor && p.status === 'paid' && ( + + )} +
+ )} +
+

Communications

{!notifs ?

Loading…

: notifs.length === 0 ?

None sent yet.

: ( diff --git a/frontend/src/admin/api.js b/frontend/src/admin/api.js index d2727dd..10c5772 100644 --- a/frontend/src/admin/api.js +++ b/frontend/src/admin/api.js @@ -144,6 +144,9 @@ export const adminApi = { offering: (id) => get(`/api/admin/offerings/${id}`), createOffering: (fields) => post('/api/admin/offerings', fields), transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }), + updateOffering: (id, fields) => patch(`/api/admin/offerings/${id}`, fields), + registrationPayments: (id) => get(`/api/admin/registrations/${id}/payments`), + recordRefund: (transactionId, status) => post(`/api/admin/payments/${transactionId}/refund`, { status }), offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`), assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }), offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`), diff --git a/platform/api/src/admin-offerings-routes.js b/platform/api/src/admin-offerings-routes.js index db89e05..a068faf 100644 --- a/platform/api/src/admin-offerings-routes.js +++ b/platform/api/src/admin-offerings-routes.js @@ -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);