MeetyyAPI
Documentation / API Reference / Course pricing v2 โ€” response

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_count increments 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 USD and BDT, 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.

  1. The envelope is data.mentor.mentor_profile, not data.profile. GET /mentor/profile returns a MentorResource wrapping the user, with the profile nested under mentor_profile. That predates this round and is not changing; the spec's example shows a shape this API has never served.

  2. Nothing is backfilled. Every existing mentor's COLUMN stays null until 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_currency resolves to USD on 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 and Currency::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

  1. is_active defaults to false โ€” see ยง6 above.
  2. A fixed-amount code's currency must equal the course's currency: 422 keyed on currency, 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: true for them.