Course pricing v2 โ response
Reply to course-pricing-v2.md (27 Aug 2026).
Everything in ยงยง1โ11 has been built and tested. The four questions in ยง12 are
answered below, and the phase order in ยง13 is the order it shipped in.
- Base URL:
/api/v1 - Auth: author endpoints require a Sanctum bearer token.
GET /public/courses/{slug}is open.
Read ยง1 first. A large part of the spec describes behaviour that was already live before this round, including the whole of ยง10. Knowing which is which is the difference between "test the new thing" and "re-test the ladder".
| Marker | Meaning |
|---|---|
| โ | Shipped this round |
| ๐ฆ | Already live before this round โ no change |
| ๐ | A bug the spec uncovered, now fixed |
| โ | A deviation from the spec, or a client action |
1. What was already built
The spec assumes less exists than does. These need no work and no client change, but they are worth confirming, because three of the questions in ยง12 are already answered by code that has been in production.
| ยง | Thing | State |
|---|---|---|
| 1 | The three layers, one winner each, applied in order | ๐ฆ |
| 7 | Lowest-price-first, then priority, then specificity, then age |
๐ฆ |
| 7 | Minor-unit arithmetic, round half-up once per layer, clamp at zero | ๐ฆ |
| 7 | Zero means free enrolment, no checkout session | ๐ฆ |
| 5 | Retire-on-write while existing rows keep resolving | ๐ฆ |
| 4 | Overlap check keyed on ends_at / starts_at |
๐ฆ |
| 10 | Coupons, in full | ๐ฆ |
| 8.3 | redemptions_count moves on capture, not on checkout creation |
๐ฆ |
| 8 | Enrolment echoes offer_id, coupon_id, discount_amount, list_price_snapshot |
๐ฆ |
โ ยง10 is not dormant
"The client is built and merged against it, dormant behind a 404 check."
Coupons shipped a round ago. GET/POST/PUT/DELETE /mentor/courses/{course}/coupons are live, an empty list is 200 with
"coupons": [], status is derived server-side from the five cases, a bad code
is a 422 keyed on coupon_code, and deleting a code leaves the enrolments made
with it untouched.
The client can drop its 404 check. Only the two ยง10 amendments were outstanding, and both are done โ see ยง4 below.
โ ยง8.3 was already right
"
redemptions_countincrements on payment capture, not on checkout creation."
This is how coupon redemption has always worked: the counter moves in
activateAfterPayment(), and a free enrolment counts immediately because there
is no capture to wait for. Special links now use the same path, so the two
counters cannot drift apart.
2. What shipped this round
โ ยง3 โ one currency per mentor
default_currency on the mentor profile. The column is nullable; the API resolves null to USD.
// PUT /mentor/profile { "default_currency": "BDT" }
{
"id": "01a0โฆ",
"default_currency": "BDT", // ISO 4217, uppercase. Never null on the wire.
// โฆ everything else unchanged
}
Accepted: USD EUR GBP BDT INR AUD CAD โ the client's list exactly.
Lower case is accepted and normalised, so "usd" is not a 422 over letter case.
Superseded. The picker has since narrowed to
USDandBDT, and the switch is now refused while the mentor holds priced items. A mentor already on one of the retired codes keeps it and can still save it. See payment-system.md ยง One currency per mentor.
โ Two corrections to the spec's ยง3.
-
The envelope is
data.mentor.mentor_profile, notdata.profile.GET /mentor/profilereturns aMentorResourcewrapping the user, with the profile nested undermentor_profile. That predates this round and is not changing; the spec's example shows a shape this API has never served. -
Nothing is backfilled. Every existing mentor's COLUMN stays
nulluntil they open Settings. Stamping a currency none of them chose is inventing a decision on their behalf.Since updated. The column is still never backfilled, but the API no longer exposes the null:
default_currencyresolves toUSDon the way out. Every client that saw the null reimplemented the same fallback, so the server does it once. "Chose nothing" remains a real state internally โ the mentee price filter andCurrency::selectableCodesFor()both read it.
ยง12.2 โ confirmed, and slightly wider than asked. It is on the auth payload's
mentor_profile, and on /me. Two sign-in paths โ email verification and OAuth
callback โ were not loading mentorProfile at all, so mentor_profile came back
absent from them; both now load it. A mentor who signs in with Google gets the
same cached user as one who signs in with a password.
It is also on the public mentor profile, deliberately: that payload already
exposed hourly_rate as a bare number with no currency beside it.
Nothing is converted, ever. Amounts keep their number and their stored code. This is tested rather than merely asserted.
โ ยง6 โ created paused
is_active now defaults to false in the column, in the model, and in the
overlap check.
โ The default is a safety net, not a veto. ยง6 says you may take the value
from the payload, and it does: a client that explicitly sends is_active: true
gets a live rate. A client that says nothing gets a paused row. The client always
sends false, so this is invisible to it โ but an API client that omits the key
no longer gets a live discount by accident.
โ This reaches community coupons too. Coupon is one model behind both the
course and community surfaces, so POST /mentor/communities/{id}/coupons now
also returns is_active: false / status: "paused". If the community coupon
screen has no pause switch on its list rows, it needs one โ otherwise a code
created there can never go live.
โ
ยง5 โ any_community_member retired
Refused on create with a 422 keyed on audience_type. Existing rows keep
resolving, stay editable, stay deletable, and keep reporting
is_deprecated: true for the "Legacy audience" badge.
audience_type accepted on create is now exactly specific_community |
special_link.
โ ยง4 / ยง7 / ยง8 โ special links
A rate row with a token, caps and a counter.
// POST /mentor/courses/{course}/pricing
{
"audience_type": "special_link",
"audience_type_label": "Shared link",
"share_token": "sp_9Kd2xQ9mB4vNc1LpR7wZ",
"share_url": "https://โฆ/courses/web-development-course?special_price=sp_9Kd2โฆ",
"max_redemptions": 50,
"per_user_limit": 1,
"redemptions_count": 0,
// โฆ every other field exactly as a community rate serves it
}
All six keys are on every row, null / 0 on a community rate, so the
client's unconditional reads need no ?? null.
| Property | How |
|---|---|
| Unguessable | 32 bytes of CSPRNG output, base64url, sp_ prefixed |
| Unique across all courses | Unique index; a token alone identifies a row |
| Immutable | No rotate endpoint; the key is stripped from updates in both the request and the service |
| Server-minted | A client-proposed share_token is ignored on create |
| Compared safely | hash_equals, everywhere it is checked |
share_url is built server-side from config('app.url') and the course slug,
with the parameter spelled special_price. The client can drop its
window.location.origin fallback.
Validation. community_id is forbidden on a link and required on a community
rate; caps are forbidden on a community rate; max_redemptions may be lowered
below redemptions_count โ that is "stop now", and the row simply reads as
used up, never a 422.
Overlap. Special links do not overlap-check against anything, including each other. Two live links at different prices is the normal case.
Resolution. A link is a candidate only when its token was presented, it is
switched on, it is inside its window, it is not used up, and โ for an
authenticated viewer โ they are under per_user_limit. It then competes on
price like any other rate. It is the narrowest audience on the specificity
ladder, so it wins a tie at equal price.
ยง6's worked example resolves exactly as written, including the last row: a
member of React Devs BD holding the link pays 39.20, because the link is
cheaper and lowest-price-first is unchanged. A coupon stacks on the special
price, not on the base price โ that is the row most likely to be got wrong, and
it has its own test.
The signed-out viewer. per_user_limit is skipped when there is no identity
to count against, and enforced at enrolment, where there always is one. A
signed-out visitor holding a link sees the special price on the public course
page.
ยง8.1 โ a bad token is never an error. Unknown, paused, expired, used-up, or
belonging to another course: the price resolves without it and the response is
200. There is no new field saying the token was ignored; the client badges off
my_pricing.rate.audience_type, which is already correct.
ยง8.2 โ re-validated at enrol. The quote is advisory. A link that hits its cap between the page load and the click charges the real price.
ยง8 โ pricing_rule_id on the enrolment resource. Layer 1 now sits beside
offer_id and coupon_id, so "you got this through the newsletter link" costs
no second lookup. It is unconstrained, like its neighbours: deleting a rate does
not touch what it sold.
โ
ยง9a โ course_type
recorded | live, defaulting to recorded for every existing row, editable on
update, with course_type_label beside it. Filterable as ?course_type=live,
and offered under data.course_types on GET /public/course-filters.
String-backed rather than integer-backed, against this project's usual default:
the value appears in a public URL, and ?course_type=2 is a query string nobody
can debug.
โ ยง9b โ communities echo and enforcement
communities is now on both the mentor and public course payloads:
"communities": [
{ "id": "01a0โฆ", "name": "React Devs BD", "slug": "react-devs-bd", "avatar_url": null }
]
โ avatar_url maps to the community's icon collection. The community's own
resources spell it icon_url; this key is the client's, mapped for it.
community_only now requires exactly one community โ none and two are both
422s keyed on community_ids, with different messages.
๐ A community-only course used to 404 for its own members. The public lookup excluded the visibility outright, for everyone, so a member who had paid for access got "this course isn't available". It now 404s at the non-member, which is what ยง9b actually asks for, and opens for a member, for the author, and for a valid special-price token.
Community-only courses stay out of the public catalogue, and enrolment was already refused for non-members.
โ ยง10 โ the two coupon amendments
is_activedefaults tofalseโ see ยง6 above.- A fixed-amount code's
currencymust equal the course's currency:422keyed oncurrency, never a conversion. A percentage code carries no currency and is skipped.
3. ๐ Two bugs the spec uncovered
Neither was in the spec's scope. Both were found by reading it against the code.
A date-only ends_at expired a day early
The rate dialog sends "2026-09-30", and the column is a timestamp. Parsed
as-is, that is midnight, so a rate the author advertised "until 30 September"
stopped applying at 00:00 on the 30th โ the whole final day lost, on the day it
matters most.
ends_at is now read as the end of that day and starts_at as the start of
its own. A full datetime is passed through untouched, so a client that means
14:00 still gets 14:00.
(Coupons already did this correctly, which is how the discrepancy was spotted.)
The overlap check read an omitted is_active as live
Harmless until ยง6 flipped the default; after it, a create that omitted the key would have been overlap-checked as a live rate and rejected โ while saving switched off, colliding with nothing. Fixed alongside the default.
4. ยง12 โ the four answers
1. special_price and special_price_token โ confirmed
Both spellings accepted exactly as merged:
GET /public/courses/{slug}?special_price_token=sp_โฆ
POST /mentee/courses/{course}/pricing { "special_price_token": "sp_โฆ" }
POST /mentee/courses/{course}/enroll { "special_price_token": "sp_โฆ" }
and share_url carries ?special_price=.
2. default_currency on the auth payload โ confirmed
Plus the two sign-in paths that were dropping mentor_profile entirely. See ยง2.
3. Does a valid token open a community_only course? โ yes
As assumed. The author created the link and chose who to send it to, so a link to a course the recipient cannot open does nothing at all โ and refusing it would make an author's two features contradict each other: they would have handed out a discount on a page that returns 404.
The token still has to be live. A paused, expired or used-up link is not a key, and a member's own access is unaffected either way.
4. Should changing default_currency be blocked? โ no, allowed with a warning
Blocking traps a mentor who mispicked on day one, and it does not prevent the mixed-currency catalogue it appears to โ the old amounts are already stored in the old code either way. Nothing converts in either design, so blocking buys nothing and costs a support ticket.
No client change. Render the Settings card's warning copy as written.
5. Client actions
Nothing here is blocking; the client works unchanged against all of it.
| Do this | Why |
|---|---|
| Drop the coupon 404 check | ยง10 has been live for a round โ see ยง1 |
Drop the window.location.origin fallback for share_url |
Sent server-side, correctly, always |
| Add a pause switch to the community coupon list rows | Codes there are created paused now too โ ยง2 |
Read data.mentor.mentor_profile.default_currency |
Not data.profile โ that envelope does not exist |
Expect avatar_url on communities[] |
Mapped from the community icon collection |
Nothing for pricing_rule_id |
Additive, on the enrolment resource, ignore until useful |
6. Invariants, as implemented
- One currency per mentor, one per course. A mismatch is a 422, never a conversion.
- Nothing recomputes retroactively. Editing a rate, pausing a link, changing the profile currency or deleting a code changes what the next person pays.
- Author only. A co-author is a credit, not a permission.
- A share token is a credential. Unguessable, immutable, revocable only by pausing or deleting its row, never enumerated on a learner-facing payload.
- A free course ignores layers 2 and 3 โ except a paid course whose rate
resolved free for this viewer, which is
is_free: truefor them.