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
+62
View File
@@ -188,6 +188,12 @@ function OfferingDetail({ offeringId, canAuthor, onBack }) {
)} )}
</div> </div>
<div className={styles.card}>
<h3>Pricing</h3>
<p>{offering.price_amount === null ? <span className={styles.muted}>Free — no checkout required.</span> : <>{(offering.price_amount / 100).toFixed(2)} {offering.price_currency.toUpperCase()}</>}</p>
{canAuthor && <SetPriceForm offeringId={offeringId} current={offering} onSaved={reload} />}
</div>
<div className={styles.card}> <div className={styles.card}>
<h3>Launch readiness</h3> <h3>Launch readiness</h3>
{!readiness ? <p className={styles.muted}>Loading…</p> : ( {!readiness ? <p className={styles.muted}>Loading…</p> : (
@@ -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 (
<form onSubmit={submit} style={{ marginTop: '0.5rem', display: 'flex', gap: '0.5rem', alignItems: 'end' }}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Price (blank = free, no checkout)</label>
<input className={styles.input} type="number" min="0" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} /></div>
<div><label className={styles.label}>Currency</label>
<input className={styles.input} value={currency} onChange={(e) => setCurrency(e.target.value.toLowerCase())} style={{ width: '5rem' }} /></div>
<button className={styles.btnSecondary} disabled={busy}>Save price</button>
</form>
)
}
function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) { function AddAgreementRequirementForm({ offeringId, allAgreements, onAdded }) {
const [agreementId, setAgreementId] = useState('') const [agreementId, setAgreementId] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -321,6 +357,7 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
const [reg, setReg] = useState(null) const [reg, setReg] = useState(null)
const [agreementStatus, setAgreementStatus] = useState(null) const [agreementStatus, setAgreementStatus] = useState(null)
const [notifs, setNotifs] = useState(null) const [notifs, setNotifs] = useState(null)
const [payments, setPayments] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [notice, setNotice] = useState(null) const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false) 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.registration(registrationId).then(setReg).catch((e) => setError(e.message))
adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {}) adminApi.registrationAgreementStatus(registrationId).then(setAgreementStatus).catch(() => {})
adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {}) adminApi.registrationNotifications(registrationId).then(setNotifs).catch(() => {})
adminApi.registrationPayments(registrationId).then(setPayments).catch(() => {})
}, [registrationId]) }, [registrationId])
useEffect(reload, [reload]) useEffect(reload, [reload])
@@ -387,6 +425,30 @@ function RegistrationDetail({ registrationId, canAuthor, onBack }) {
)} )}
</div> </div>
<div className={styles.card}>
<h3>Payments</h3>
{!payments ? <p className={styles.muted}>Loading…</p> : payments.length === 0 ? <p className={styles.muted}>No checkout attempted — this offering may be free, or checkout hasn't started yet.</p> : (
<table>
<thead><tr><th>Amount</th><th>Status</th><th>Provider</th><th>Paid</th><th /></tr></thead>
<tbody>
{payments.map((p) => (
<tr key={p.transaction_id}>
<td>{(p.amount / 100).toFixed(2)} {p.currency.toUpperCase()}</td>
<td><span className={`${styles.pill} ${p.status === 'paid' ? styles.pillOk : p.status === 'failed' ? styles.pillErr : styles.pillMuted}`}>{p.status.replace('_', ' ')}</span></td>
<td className={styles.muted}>{p.provider}</td>
<td className={styles.muted}>{p.paid_at ? new Date(p.paid_at).toLocaleString() : '—'}</td>
<td>
{canAuthor && p.status === 'paid' && (
<button className={styles.linkBtn} onClick={withBusy(() => adminApi.recordRefund(p.transaction_id, 'refunded'))}>Record refund</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.card}> <div className={styles.card}>
<h3>Communications</h3> <h3>Communications</h3>
{!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : ( {!notifs ? <p className={styles.muted}>Loading…</p> : notifs.length === 0 ? <p className={styles.muted}>None sent yet.</p> : (
+3
View File
@@ -144,6 +144,9 @@ export const adminApi = {
offering: (id) => get(`/api/admin/offerings/${id}`), offering: (id) => get(`/api/admin/offerings/${id}`),
createOffering: (fields) => post('/api/admin/offerings', fields), createOffering: (fields) => post('/api/admin/offerings', fields),
transitionOffering: (id, status) => post(`/api/admin/offerings/${id}/transition`, { status }), 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`), offeringReadiness: (id) => get(`/api/admin/offerings/${id}/readiness`),
assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }), assignOfferingInstructor: (id, instructor_id, role) => post(`/api/admin/offerings/${id}/instructors`, { instructor_id, role }),
offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`), offeringAgreementRequirements: (id) => get(`/api/admin/offerings/${id}/agreement-requirements`),
+26 -1
View File
@@ -9,6 +9,7 @@
const offerings = require('./offerings'); const offerings = require('./offerings');
const notifications = require('./notifications'); const notifications = require('./notifications');
const commerce = require('./commerce');
const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses const studentsAuth = require('./students-auth'); // same direct-require convention integration-routes.js already uses
function errStatus(code) { function errStatus(code) {
@@ -17,7 +18,8 @@ function errStatus(code) {
if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE' if (code === 'DUPLICATE_SLUG' || code === 'OFFERING_FULL' || code === 'INVALID_TRANSITION' || code === 'INVALID_STATE'
|| code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED' || code === 'OFFERING_NOT_OPEN' || code === 'OFFERING_CANCELLED' || code === 'OFFERING_COMPLETED'
|| code === 'REGISTRATION_NOT_YET_OPEN' || code === 'REGISTRATION_CLOSED' || code === 'DUPLICATE_REGISTRATION' || 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; return 500;
} }
@@ -31,6 +33,7 @@ module.exports = function registerAdminOfferingsRoutes(app, deps) {
const admin = { const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)), get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(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) { function handleError(res, e) {
if (e.code) return res.status(errStatus(e.code)).json({ error: e.code, message: e.message }); 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' }); if (!offering) return res.status(404).json({ error: 'NOT_FOUND' });
res.json(offering); 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) => { admin.post('/api/admin/offerings/:id/transition', requireAdminOrOwner, async (req, res) => {
try { try {
const offering = await offerings.transitionOffering(getPool(), req.params.id, req.body.status); 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); const rows = await notifications.listForRegistration(getPool(), req.params.id);
res.json(rows.map(notifications.toAdminView)); 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) => { admin.post('/api/admin/registrations/:id/transition', requireAdminOrOwner, async (req, res) => {
try { try {
const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status); const reg = await offerings.transitionRegistration(getPool(), req.params.id, req.body.status);