Skip to main content

headless_lms_models/
credit_registrations.rs

1//! The credit registration ledger.
2//!
3//! [`transition`] is the only writer of `state`, stamping `state_entered_at`, the lifecycle
4//! timestamps and the audit event in one transaction. Policy — which transition to make, how to
5//! back off, grade mapping, enrolment choice — lives in the state machine, not here.
6use chrono::NaiveDate;
7use utoipa::ToSchema;
8
9use crate::credit_registration_events::{CreditRegistrationEventKind, NewCreditRegistrationEvent};
10use crate::prelude::*;
11
12/// What the pipeline does next with a ledger row.
13#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
14#[sqlx(type_name = "credit_registration_state", rename_all = "snake_case")]
15#[serde(rename_all = "snake_case")]
16pub enum CreditRegistrationState {
17    PendingPrerequisites,
18    PendingConsent,
19    PendingStudentNumber,
20    ReadyToSubmit,
21    CheckingEnrolment,
22    NoUsableEnrolment,
23    Submitting,
24    SubmissionUncertain,
25    AwaitingVerification,
26    Registered,
27    Duplicate,
28    NotImproved,
29    Misregistered,
30    FailedRetryable,
31    FailedPermanent,
32    Blocked,
33    Cancelled,
34    AbandonedByConsentWithdrawal,
35}
36
37impl CreditRegistrationState {
38    /// States the pipeline never leaves on its own. `terminal_at` tracks membership: stamped on
39    /// entry, cleared on exit, so an admin retry becomes visible to the stuck queries again.
40    pub fn is_terminal(self) -> bool {
41        matches!(
42            self,
43            Self::Registered
44                | Self::Duplicate
45                | Self::NotImproved
46                | Self::FailedPermanent
47                | Self::Cancelled
48                | Self::AbandonedByConsentWithdrawal
49        )
50    }
51
52    /// Entry to one of these anchors the retry window in `first_failed_at`.
53    pub fn is_failure(self) -> bool {
54        matches!(self, Self::FailedRetryable | Self::FailedPermanent)
55    }
56
57    /// Used for reporting and for the double-registration guard.
58    pub fn is_success(self) -> bool {
59        matches!(self, Self::Registered | Self::Duplicate | Self::NotImproved)
60    }
61}
62
63/// Why a ledger row is where it is; `state` says what happens to it next.
64#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
65#[sqlx(
66    type_name = "credit_registration_error_code",
67    rename_all = "snake_case"
68)]
69#[serde(rename_all = "snake_case")]
70pub enum CreditRegistrationErrorCode {
71    PersonNotFound,
72    CourseCodeNotFound,
73    EnrolmentNotFound,
74    EnrolmentNotAccepted,
75    InvalidGradeForGradeScale,
76    CourseNotAllowed,
77    InvalidCredits,
78    StudyRightNotValid,
79    AcceptorNotFound,
80    SisuValidationFailed,
81    SisuTimeout,
82    SisuTemporarilyUnavailable,
83    Misregistered,
84    Unauthorized,
85    MalformedRequest,
86    TransportError,
87    UnexpectedResponse,
88    NoGradeScaleMapping,
89    MissingUhCourseCode,
90    MissingEctsCredits,
91    RetryWindowExpired,
92    Unknown,
93}
94
95#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
96pub struct CreditRegistration {
97    pub id: Uuid,
98    pub created_at: DateTime<Utc>,
99    pub updated_at: DateTime<Utc>,
100    pub deleted_at: Option<DateTime<Utc>>,
101    pub course_module_completion_id: Uuid,
102    pub user_id: Uuid,
103    pub course_id: Uuid,
104    pub course_module_id: Uuid,
105    pub course_instance_id: Uuid,
106    pub state: CreditRegistrationState,
107    pub state_entered_at: DateTime<Utc>,
108    pub error_code: Option<CreditRegistrationErrorCode>,
109    pub error_message: Option<String>,
110    pub needs_admin_attention: bool,
111    pub enrolment_banner_dismissed_at: Option<DateTime<Utc>>,
112    pub student_number: Option<String>,
113    pub sisu_person_id: Option<String>,
114    pub uh_course_code: Option<String>,
115    pub selected_enrolment_id: Option<String>,
116    pub selected_enrolment_kind: Option<String>,
117    pub selected_enrolment_realisation_id: Option<String>,
118    pub attainment_date: Option<NaiveDate>,
119    pub attainment_language: Option<String>,
120    pub grade_scale_id: Option<String>,
121    pub grade_id: Option<String>,
122    pub credits: Option<f32>,
123    pub request_item_id: String,
124    pub submitted_attainment_id: Option<String>,
125    pub submitted_attainment_type: Option<String>,
126    pub sisu_attainment_id: Option<String>,
127    pub sisu_attainment_type: Option<String>,
128    pub submit_retry_count: i32,
129    pub verify_attempt_count: i32,
130    pub next_attempt_at: DateTime<Utc>,
131    pub first_failed_at: Option<DateTime<Utc>>,
132    pub last_attempt_at: Option<DateTime<Utc>>,
133    pub attempt_number: i32,
134    pub superseded_by_id: Option<Uuid>,
135    pub superseded_at: Option<DateTime<Utc>>,
136    pub enrolment_checked_at: Option<DateTime<Utc>>,
137    pub submitted_at: Option<DateTime<Utc>>,
138    pub registered_at: Option<DateTime<Utc>>,
139    pub terminal_at: Option<DateTime<Utc>>,
140}
141
142#[derive(Debug, Clone, PartialEq)]
143pub struct NewCreditRegistration {
144    pub course_module_completion_id: Uuid,
145    pub user_id: Uuid,
146    pub course_id: Uuid,
147    pub course_module_id: Uuid,
148    pub course_instance_id: Uuid,
149    pub attempt_number: i32,
150}
151
152/// The item id Suotar sees for the import and resolve calls. Deterministic, so a Suotar log line
153/// maps to exactly one ledger row without an id allocation table.
154pub fn import_request_item_id(registration_id: Uuid) -> String {
155    format!("cr-{registration_id}")
156}
157
158/// The item id Suotar sees for one verify poll.
159pub fn verify_request_item_id(registration_id: Uuid, verify_attempt_count: i32) -> String {
160    format!("vf-{registration_id}-{verify_attempt_count}")
161}
162
163/// Creates a ledger row at `pending_prerequisites` with a `created` event. The id is allocated here
164/// because `request_item_id` derives from it.
165pub async fn insert(
166    conn: &mut PgConnection,
167    pkey_policy: PKeyPolicy<Uuid>,
168    new: &NewCreditRegistration,
169    event_message: Option<&str>,
170) -> ModelResult<Uuid> {
171    let id = pkey_policy.into_uuid();
172    let mut tx = conn.begin().await?;
173    sqlx::query!(
174        r#"
175INSERT INTO credit_registrations (
176    id,
177    course_module_completion_id,
178    user_id,
179    course_id,
180    course_module_id,
181    course_instance_id,
182    attempt_number,
183    request_item_id
184  )
185VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
186        "#,
187        id,
188        new.course_module_completion_id,
189        new.user_id,
190        new.course_id,
191        new.course_module_id,
192        new.course_instance_id,
193        new.attempt_number,
194        import_request_item_id(id),
195    )
196    .execute(&mut *tx)
197    .await?;
198
199    crate::credit_registration_events::insert(
200        &mut tx,
201        &NewCreditRegistrationEvent {
202            message: event_message.map(str::to_string),
203            ..NewCreditRegistrationEvent::new(id, CreditRegistrationEventKind::Created)
204        },
205    )
206    .await?;
207
208    tx.commit().await?;
209    Ok(id)
210}
211
212#[derive(Debug, Clone, PartialEq)]
213pub struct Transition {
214    pub to_state: CreditRegistrationState,
215    pub error_code: Option<CreditRegistrationErrorCode>,
216    /// Scrub before passing: this is persisted.
217    pub error_message: Option<String>,
218    pub needs_admin_attention: Option<bool>,
219    pub event_kind: CreditRegistrationEventKind,
220    pub event_message: Option<String>,
221    pub actor_user_id: Option<Uuid>,
222    pub suotar_api_call_id: Option<Uuid>,
223    /// Already scrubbed `{request, response}` payload for the event row.
224    pub event_details: Option<serde_json::Value>,
225}
226
227impl Transition {
228    pub fn to(to_state: CreditRegistrationState) -> Self {
229        Self {
230            to_state,
231            error_code: None,
232            error_message: None,
233            needs_admin_attention: None,
234            event_kind: CreditRegistrationEventKind::StateChanged,
235            event_message: None,
236            actor_user_id: None,
237            suotar_api_call_id: None,
238            event_details: None,
239        }
240    }
241}
242
243/// Moves a ledger row to a new state and appends the matching audit event, atomically.
244///
245/// Also stamps, so callers must not: `state_entered_at` (every call, including a self-transition),
246/// `terminal_at` (set on entry to a terminal state, cleared on leaving one), `first_failed_at` (the
247/// first failure only), `registered_at`, `submitted_at`, `enrolment_checked_at` (on leaving
248/// `checking_enrolment`), and clears `enrolment_banner_dismissed_at` on entry to
249/// `no_usable_enrolment` so a fresh enrolment problem shows the banner again.
250pub async fn transition(
251    conn: &mut PgConnection,
252    id: Uuid,
253    transition: &Transition,
254) -> ModelResult<CreditRegistration> {
255    let mut tx = conn.begin().await?;
256
257    let before = sqlx::query_as!(
258        CreditRegistration,
259        r#"
260SELECT *
261FROM credit_registrations
262WHERE id = $1
263  AND deleted_at IS NULL
264FOR UPDATE
265        "#,
266        id
267    )
268    .fetch_one(&mut *tx)
269    .await?;
270
271    let to_state = transition.to_state;
272    let after = sqlx::query_as!(
273        CreditRegistration,
274        r#"
275UPDATE credit_registrations
276SET state = $2::credit_registration_state,
277  -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
278  -- one transaction would share an instant and the timeline would lose their order.
279  state_entered_at = clock_timestamp(),
280  error_code = $3,
281  error_message = $4,
282  needs_admin_attention = COALESCE($5, needs_admin_attention),
283  -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
284  terminal_at = CASE
285    WHEN $6 THEN COALESCE(terminal_at, now())
286    ELSE NULL
287  END,
288  first_failed_at = CASE
289    WHEN $7 THEN COALESCE(first_failed_at, now())
290    ELSE first_failed_at
291  END,
292  registered_at = CASE
293    WHEN $2::credit_registration_state = 'registered' THEN COALESCE(registered_at, now())
294    ELSE registered_at
295  END,
296  submitted_at = CASE
297    WHEN $2::credit_registration_state = 'submitting' THEN now()
298    ELSE submitted_at
299  END,
300  enrolment_checked_at = CASE
301    WHEN state = 'checking_enrolment'
302    AND $2::credit_registration_state <> 'checking_enrolment' THEN now()
303    ELSE enrolment_checked_at
304  END,
305  enrolment_banner_dismissed_at = CASE
306    WHEN $2::credit_registration_state = 'no_usable_enrolment' THEN NULL
307    ELSE enrolment_banner_dismissed_at
308  END
309WHERE id = $1
310  AND deleted_at IS NULL
311RETURNING *
312        "#,
313        id,
314        to_state as CreditRegistrationState,
315        transition.error_code as Option<CreditRegistrationErrorCode>,
316        transition.error_message,
317        transition.needs_admin_attention,
318        to_state.is_terminal(),
319        to_state.is_failure(),
320    )
321    .fetch_one(&mut *tx)
322    .await?;
323
324    crate::credit_registration_events::insert(
325        &mut tx,
326        &NewCreditRegistrationEvent {
327            credit_registration_id: id,
328            kind: transition.event_kind,
329            from_state: Some(before.state),
330            to_state: Some(to_state),
331            error_code: transition.error_code,
332            message: transition.event_message.clone(),
333            suotar_api_call_id: transition.suotar_api_call_id,
334            actor_user_id: transition.actor_user_id,
335            details: transition.event_details.clone(),
336        },
337    )
338    .await?;
339
340    tx.commit().await?;
341    Ok(after)
342}
343
344/// Claims up to `limit` due rows in the given states for this worker.
345///
346/// The `SKIP LOCKED` row locks live until the caller's transaction ends, so callers must pass a
347/// transaction. Rows on a paused course module are never claimed, enforced here so no phase can
348/// forget it.
349pub async fn claim_due(
350    conn: &mut PgConnection,
351    states: &[CreditRegistrationState],
352    limit: i64,
353) -> ModelResult<Vec<CreditRegistration>> {
354    let res = sqlx::query_as!(
355        CreditRegistration,
356        r#"
357WITH due AS (
358  SELECT cr.id
359  FROM credit_registrations cr
360    LEFT JOIN course_module_suotar_configurations c ON c.course_module_id = cr.course_module_id
361    AND c.deleted_at IS NULL
362  WHERE cr.deleted_at IS NULL
363    AND cr.superseded_by_id IS NULL
364    AND cr.state = ANY($1::credit_registration_state [])
365    AND cr.next_attempt_at <= now()
366    AND c.paused_at IS NULL
367  ORDER BY cr.next_attempt_at
368  FOR UPDATE OF cr SKIP LOCKED
369  LIMIT $2
370)
371UPDATE credit_registrations cr
372SET last_attempt_at = now()
373FROM due
374WHERE cr.id = due.id
375RETURNING cr.*
376        "#,
377        states as &[CreditRegistrationState],
378        limit,
379    )
380    .fetch_all(conn)
381    .await?;
382    Ok(res)
383}
384
385pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<CreditRegistration> {
386    let res = sqlx::query_as!(
387        CreditRegistration,
388        r#"
389SELECT *
390FROM credit_registrations
391WHERE id = $1
392  AND deleted_at IS NULL
393        "#,
394        id
395    )
396    .fetch_one(conn)
397    .await?;
398    Ok(res)
399}
400
401pub async fn get_by_ids(
402    conn: &mut PgConnection,
403    ids: &[Uuid],
404) -> ModelResult<Vec<CreditRegistration>> {
405    let res = sqlx::query_as!(
406        CreditRegistration,
407        r#"
408SELECT *
409FROM credit_registrations
410WHERE id = ANY($1::uuid [])
411  AND deleted_at IS NULL
412        "#,
413        ids
414    )
415    .fetch_all(conn)
416    .await?;
417    Ok(res)
418}
419
420/// The live (non-superseded) row for a completion.
421pub async fn get_live_by_completion_id(
422    conn: &mut PgConnection,
423    course_module_completion_id: Uuid,
424) -> ModelResult<Option<CreditRegistration>> {
425    let res = sqlx::query_as!(
426        CreditRegistration,
427        r#"
428SELECT *
429FROM credit_registrations
430WHERE course_module_completion_id = $1
431  AND superseded_by_id IS NULL
432  AND deleted_at IS NULL
433        "#,
434        course_module_completion_id
435    )
436    .fetch_optional(conn)
437    .await?;
438    Ok(res)
439}
440
441/// Every attempt for a completion, newest first. Superseded rows are included on purpose: if Sisu
442/// ended up holding both attainments the student must see both.
443pub async fn get_all_attempts_by_completion_id(
444    conn: &mut PgConnection,
445    course_module_completion_id: Uuid,
446) -> ModelResult<Vec<CreditRegistration>> {
447    let res = sqlx::query_as!(
448        CreditRegistration,
449        r#"
450SELECT *
451FROM credit_registrations
452WHERE course_module_completion_id = $1
453  AND deleted_at IS NULL
454ORDER BY attempt_number DESC
455        "#,
456        course_module_completion_id
457    )
458    .fetch_all(conn)
459    .await?;
460    Ok(res)
461}
462
463pub async fn get_by_user_id(
464    conn: &mut PgConnection,
465    user_id: Uuid,
466) -> ModelResult<Vec<CreditRegistration>> {
467    let res = sqlx::query_as!(
468        CreditRegistration,
469        r#"
470SELECT *
471FROM credit_registrations
472WHERE user_id = $1
473  AND deleted_at IS NULL
474ORDER BY created_at DESC
475        "#,
476        user_id
477    )
478    .fetch_all(conn)
479    .await?;
480    Ok(res)
481}
482
483pub async fn get_by_course_id(
484    conn: &mut PgConnection,
485    course_id: Uuid,
486) -> ModelResult<Vec<CreditRegistration>> {
487    let res = sqlx::query_as!(
488        CreditRegistration,
489        r#"
490SELECT *
491FROM credit_registrations
492WHERE course_id = $1
493  AND deleted_at IS NULL
494ORDER BY created_at DESC
495        "#,
496        course_id
497    )
498    .fetch_all(conn)
499    .await?;
500    Ok(res)
501}
502
503/// Frozen copy of what we are about to submit. Written once, before the row leaves
504/// `checking_enrolment`: a later regrade must not alter a submitted row.
505#[derive(Debug, Clone, PartialEq)]
506pub struct PayloadSnapshot {
507    pub student_number: String,
508    pub sisu_person_id: String,
509    pub uh_course_code: String,
510    pub selected_enrolment_id: Option<String>,
511    pub selected_enrolment_kind: Option<String>,
512    pub selected_enrolment_realisation_id: Option<String>,
513    pub attainment_date: NaiveDate,
514    pub attainment_language: String,
515    pub grade_scale_id: String,
516    pub grade_id: String,
517    pub credits: f32,
518}
519
520pub async fn set_payload_snapshot(
521    conn: &mut PgConnection,
522    id: Uuid,
523    snapshot: &PayloadSnapshot,
524) -> ModelResult<()> {
525    sqlx::query!(
526        r#"
527UPDATE credit_registrations
528SET student_number = $2,
529  sisu_person_id = $3,
530  uh_course_code = $4,
531  selected_enrolment_id = $5,
532  selected_enrolment_kind = $6,
533  selected_enrolment_realisation_id = $7,
534  attainment_date = $8,
535  attainment_language = $9,
536  grade_scale_id = $10,
537  grade_id = $11,
538  credits = $12
539WHERE id = $1
540  AND deleted_at IS NULL
541        "#,
542        id,
543        snapshot.student_number,
544        snapshot.sisu_person_id,
545        snapshot.uh_course_code,
546        snapshot.selected_enrolment_id,
547        snapshot.selected_enrolment_kind,
548        snapshot.selected_enrolment_realisation_id,
549        snapshot.attainment_date,
550        snapshot.attainment_language,
551        snapshot.grade_scale_id,
552        snapshot.grade_id,
553        snapshot.credits,
554    )
555    .execute(conn)
556    .await?;
557    Ok(())
558}
559
560pub async fn set_submitted_attainment(
561    conn: &mut PgConnection,
562    id: Uuid,
563    submitted_attainment_id: &str,
564    submitted_attainment_type: Option<&str>,
565) -> ModelResult<()> {
566    sqlx::query!(
567        r#"
568UPDATE credit_registrations
569SET submitted_attainment_id = $2,
570  submitted_attainment_type = $3
571WHERE id = $1
572  AND deleted_at IS NULL
573        "#,
574        id,
575        submitted_attainment_id,
576        submitted_attainment_type,
577    )
578    .execute(conn)
579    .await?;
580    Ok(())
581}
582
583pub async fn set_sisu_attainment(
584    conn: &mut PgConnection,
585    id: Uuid,
586    sisu_attainment_id: &str,
587    sisu_attainment_type: Option<&str>,
588) -> ModelResult<()> {
589    sqlx::query!(
590        r#"
591UPDATE credit_registrations
592SET sisu_attainment_id = $2,
593  sisu_attainment_type = $3
594WHERE id = $1
595  AND deleted_at IS NULL
596        "#,
597        id,
598        sisu_attainment_id,
599        sisu_attainment_type,
600    )
601    .execute(conn)
602    .await?;
603    Ok(())
604}
605
606/// Defers when the pipeline may next claim this row; the delay is the caller's policy.
607///
608/// Does not touch `first_failed_at`, which [`transition`] owns: states that wait on a human must
609/// defer to stay out of `claim_due`'s `LIMIT`, and anchoring the retry window there would expire them.
610pub async fn schedule_next_attempt(
611    conn: &mut PgConnection,
612    id: Uuid,
613    next_attempt_at: DateTime<Utc>,
614) -> ModelResult<()> {
615    sqlx::query!(
616        r#"
617UPDATE credit_registrations
618SET next_attempt_at = $2
619WHERE id = $1
620  AND deleted_at IS NULL
621        "#,
622        id,
623        next_attempt_at,
624    )
625    .execute(conn)
626    .await?;
627    Ok(())
628}
629
630pub async fn increment_submit_retry_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
631    let res = sqlx::query!(
632        r#"
633UPDATE credit_registrations
634SET submit_retry_count = submit_retry_count + 1
635WHERE id = $1
636  AND deleted_at IS NULL
637RETURNING submit_retry_count
638        "#,
639        id
640    )
641    .fetch_one(conn)
642    .await?;
643    Ok(res.submit_retry_count)
644}
645
646pub async fn increment_verify_attempt_count(conn: &mut PgConnection, id: Uuid) -> ModelResult<i32> {
647    let res = sqlx::query!(
648        r#"
649UPDATE credit_registrations
650SET verify_attempt_count = verify_attempt_count + 1
651WHERE id = $1
652  AND deleted_at IS NULL
653RETURNING verify_attempt_count
654        "#,
655        id
656    )
657    .fetch_one(conn)
658    .await?;
659    Ok(res.verify_attempt_count)
660}
661
662pub async fn set_needs_admin_attention(
663    conn: &mut PgConnection,
664    id: Uuid,
665    needs_admin_attention: bool,
666) -> ModelResult<()> {
667    sqlx::query!(
668        r#"
669UPDATE credit_registrations
670SET needs_admin_attention = $2
671WHERE id = $1
672  AND deleted_at IS NULL
673        "#,
674        id,
675        needs_admin_attention,
676    )
677    .execute(conn)
678    .await?;
679    Ok(())
680}
681
682/// The student dismissed the in-course-material re-enrol banner for this registration.
683pub async fn dismiss_enrolment_banner(
684    conn: &mut PgConnection,
685    id: Uuid,
686    user_id: Uuid,
687) -> ModelResult<()> {
688    sqlx::query!(
689        r#"
690UPDATE credit_registrations
691SET enrolment_banner_dismissed_at = now()
692WHERE id = $1
693  AND user_id = $2
694  AND deleted_at IS NULL
695        "#,
696        id,
697        user_id,
698    )
699    .execute(conn)
700    .await?;
701    Ok(())
702}
703
704/// Points an old attempt at the newer one that replaced it. The old row keeps its state and
705/// `terminal_at`: it really was registered.
706pub async fn mark_superseded(
707    conn: &mut PgConnection,
708    id: Uuid,
709    superseded_by_id: Uuid,
710) -> ModelResult<()> {
711    sqlx::query!(
712        r#"
713UPDATE credit_registrations
714SET superseded_by_id = $2,
715  superseded_at = now()
716WHERE id = $1
717  AND deleted_at IS NULL
718        "#,
719        id,
720        superseded_by_id,
721    )
722    .execute(conn)
723    .await?;
724    Ok(())
725}
726
727/// Live rows per state, for the dashboard funnel.
728pub async fn count_by_state(
729    conn: &mut PgConnection,
730) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
731    let rows = sqlx::query!(
732        r#"
733SELECT state AS "state: CreditRegistrationState",
734  COUNT(*) AS "count!"
735FROM credit_registrations
736WHERE deleted_at IS NULL
737GROUP BY state
738        "#,
739    )
740    .fetch_all(conn)
741    .await?;
742    Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use crate::course_module_completions::{
749        CourseModuleCompletionGranter, NewCourseModuleCompletion,
750    };
751    use crate::credit_registration_events::CreditRegistrationEventKind;
752    use crate::test_helper::*;
753
754    async fn insert_registration(
755        conn: &mut PgConnection,
756        user: Uuid,
757        course: Uuid,
758        course_instance: Uuid,
759        course_module: Uuid,
760    ) -> Uuid {
761        let completion = crate::course_module_completions::insert(
762            conn,
763            PKeyPolicy::Generate,
764            &NewCourseModuleCompletion {
765                course_id: course,
766                course_module_id: course_module,
767                user_id: user,
768                completion_date: Utc::now(),
769                completion_registration_attempt_date: None,
770                completion_language: "en".to_string(),
771                eligible_for_ects: true,
772                email: "student@example.com".to_string(),
773                grade: Some(4),
774                passed: true,
775            },
776            CourseModuleCompletionGranter::Automatic,
777        )
778        .await
779        .unwrap();
780
781        insert(
782            conn,
783            PKeyPolicy::Generate,
784            &NewCreditRegistration {
785                course_module_completion_id: completion.id,
786                user_id: user,
787                course_id: course,
788                course_module_id: course_module,
789                course_instance_id: course_instance,
790                attempt_number: 1,
791            },
792            None,
793        )
794        .await
795        .unwrap()
796    }
797
798    #[tokio::test]
799    async fn transition_stamps_state_entered_at_and_writes_an_event() {
800        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
801        let id =
802            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
803        let before = get_by_id(tx.as_mut(), id).await.unwrap();
804
805        let after = transition(
806            tx.as_mut(),
807            id,
808            &Transition::to(CreditRegistrationState::PendingConsent),
809        )
810        .await
811        .unwrap();
812
813        assert_eq!(after.state, CreditRegistrationState::PendingConsent);
814        assert!(after.state_entered_at > before.state_entered_at);
815
816        let events = crate::credit_registration_events::get_by_registration_id(tx.as_mut(), id)
817            .await
818            .unwrap();
819        // The `created` event from insert plus this state change, newest first.
820        assert_eq!(events.len(), 2);
821        assert_eq!(events[1].kind, CreditRegistrationEventKind::Created);
822        assert_eq!(events[0].kind, CreditRegistrationEventKind::StateChanged);
823        assert_eq!(
824            events[0].from_state,
825            Some(CreditRegistrationState::PendingPrerequisites)
826        );
827        assert_eq!(
828            events[0].to_state,
829            Some(CreditRegistrationState::PendingConsent)
830        );
831    }
832
833    #[tokio::test]
834    async fn consecutive_state_changes_stay_ordered_inside_one_transaction() {
835        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
836        let id =
837            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
838
839        let first = transition(
840            tx.as_mut(),
841            id,
842            &Transition::to(CreditRegistrationState::PendingConsent),
843        )
844        .await
845        .unwrap();
846        let second = transition(
847            tx.as_mut(),
848            id,
849            &Transition::to(CreditRegistrationState::PendingStudentNumber),
850        )
851        .await
852        .unwrap();
853        assert!(second.state_entered_at > first.state_entered_at);
854    }
855
856    #[tokio::test]
857    async fn transition_stamps_lifecycle_timestamps() {
858        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
859        let id =
860            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
861
862        let checking = transition(
863            tx.as_mut(),
864            id,
865            &Transition::to(CreditRegistrationState::CheckingEnrolment),
866        )
867        .await
868        .unwrap();
869        assert!(checking.enrolment_checked_at.is_none());
870        assert!(checking.submitted_at.is_none());
871        assert!(checking.terminal_at.is_none());
872
873        // Leaving checking_enrolment is what stamps enrolment_checked_at.
874        let submitting = transition(
875            tx.as_mut(),
876            id,
877            &Transition::to(CreditRegistrationState::Submitting),
878        )
879        .await
880        .unwrap();
881        assert!(submitting.enrolment_checked_at.is_some());
882        assert!(submitting.submitted_at.is_some());
883        assert!(submitting.registered_at.is_none());
884        assert!(submitting.terminal_at.is_none());
885
886        let registered = transition(
887            tx.as_mut(),
888            id,
889            &Transition::to(CreditRegistrationState::Registered),
890        )
891        .await
892        .unwrap();
893        assert!(registered.registered_at.is_some());
894        assert!(registered.terminal_at.is_some());
895        assert_eq!(registered.submitted_at, submitting.submitted_at);
896        assert_eq!(
897            registered.enrolment_checked_at,
898            submitting.enrolment_checked_at
899        );
900    }
901
902    #[tokio::test]
903    async fn terminal_at_holds_between_terminal_states_and_clears_on_a_retry() {
904        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
905        let id =
906            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
907
908        let first = transition(
909            tx.as_mut(),
910            id,
911            &Transition::to(CreditRegistrationState::Cancelled),
912        )
913        .await
914        .unwrap();
915        let terminal_at = first.terminal_at.unwrap();
916
917        let second = transition(
918            tx.as_mut(),
919            id,
920            &Transition::to(CreditRegistrationState::FailedPermanent),
921        )
922        .await
923        .unwrap();
924        assert_eq!(second.terminal_at, Some(terminal_at));
925
926        let retried = transition(
927            tx.as_mut(),
928            id,
929            &Transition::to(CreditRegistrationState::ReadyToSubmit),
930        )
931        .await
932        .unwrap();
933        assert_eq!(retried.terminal_at, None);
934    }
935
936    #[tokio::test]
937    async fn entering_no_usable_enrolment_clears_a_dismissed_banner() {
938        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
939        let id =
940            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
941
942        transition(
943            tx.as_mut(),
944            id,
945            &Transition::to(CreditRegistrationState::NoUsableEnrolment),
946        )
947        .await
948        .unwrap();
949        dismiss_enrolment_banner(tx.as_mut(), id, user)
950            .await
951            .unwrap();
952        assert!(
953            get_by_id(tx.as_mut(), id)
954                .await
955                .unwrap()
956                .enrolment_banner_dismissed_at
957                .is_some()
958        );
959
960        transition(
961            tx.as_mut(),
962            id,
963            &Transition::to(CreditRegistrationState::ReadyToSubmit),
964        )
965        .await
966        .unwrap();
967        // Still dismissed: only a fresh enrolment problem brings the banner back.
968        assert!(
969            get_by_id(tx.as_mut(), id)
970                .await
971                .unwrap()
972                .enrolment_banner_dismissed_at
973                .is_some()
974        );
975
976        let back = transition(
977            tx.as_mut(),
978            id,
979            &Transition::to(CreditRegistrationState::NoUsableEnrolment),
980        )
981        .await
982        .unwrap();
983        assert_eq!(back.enrolment_banner_dismissed_at, None);
984    }
985
986    #[tokio::test]
987    async fn transition_carries_the_error_and_leaves_the_admin_flag_alone_unless_asked() {
988        insert_data!(:tx, :user, :org, :course, :instance, :course_module);
989        let id =
990            insert_registration(tx.as_mut(), user, course, instance.id, course_module.id).await;
991        set_needs_admin_attention(tx.as_mut(), id, true)
992            .await
993            .unwrap();
994
995        let failed = transition(
996            tx.as_mut(),
997            id,
998            &Transition {
999                error_code: Some(CreditRegistrationErrorCode::EnrolmentNotFound),
1000                error_message: Some("no accepted enrolment".to_string()),
1001                ..Transition::to(CreditRegistrationState::FailedPermanent)
1002            },
1003        )
1004        .await
1005        .unwrap();
1006        assert_eq!(
1007            failed.error_code,
1008            Some(CreditRegistrationErrorCode::EnrolmentNotFound)
1009        );
1010        assert!(failed.needs_admin_attention);
1011
1012        let resolved = transition(
1013            tx.as_mut(),
1014            id,
1015            &Transition {
1016                needs_admin_attention: Some(false),
1017                ..Transition::to(CreditRegistrationState::ReadyToSubmit)
1018            },
1019        )
1020        .await
1021        .unwrap();
1022        assert!(!resolved.needs_admin_attention);
1023        assert_eq!(resolved.error_code, None);
1024    }
1025}