diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 8f9ceac8c..f721cfeee 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -124,41 +124,57 @@ fee. A regular membership starting **after** it pays the half year; one starting or before pays the full year. Policy for the year, so it lives on the period rather than being retyped on each send. -### Fee cycle +### Payment emails -The one operation that asks every member of a period who has not paid for what they -owe. It is opened for a period, not for a selection: who is asked follows from the -period, which is what makes "has everybody been asked exactly once" answerable. +The one operation that asks a selection of members for what they owe for a period. +One send, whichever statements it turns out to need — not two actions the treasurer +picks between, and not a bulk reminder. -Not a "bulk reminder" and not two sends. One cycle, one confirmation. +"Payment emails" in the interface; `ContributionEmail` in the code, where the noun +sits beside contribution period and contribution reminder. -### The partition +### Contribution reminder and incasso notification -The split of a fee cycle into the two groups that receive different statements, -decided by the `incasso` flag on the membership each decision is judged against. -Called a **group** on the wire and in the code — `FeeCycleGroup`, whose values are -`DIRECT_DEBIT` and `TRANSFER` — and "the partition" only in prose about the pair. - -Because the flag decides the side, not having it is not a warning. There is no wrong -side to be selected for. - -### Payment request and pre-notification - -The two statements a fee cycle sends. A **payment request** asks a member paying by -transfer to pay what they owe by a due date. A **pre-notification** tells a member +The two emails a send puts out. A **contribution reminder** asks a member paying by +transfer to pay what they owe by a due date. An **incasso notification** tells a member paying by direct debit what will be taken and on what date, and asks for nothing. +Which one a member gets is their `incasso` flag's choice, stated per row as the +member's **kind** — `ContributionEmailKind`, whose values are `REMINDER` and +`INCASSO_NOTIFICATION`. A default rather than a rule: the treasurer may move a member +onto the other one, and a **switched** row says so. + Different statements, so different records: the treasurer's question is which one a member received. Neither quotes an amount without the reason that amount applies. +### Send to + +The checkbox on the first step of the payment-email wizard, and the selection itself: a +member is written to when their box is ticked, and only then. A member the api warns about +starts unticked, and one it cannot write to at all has no box. It replaced **forcibly +include**, which was the same gesture but appeared only on the rows the api had warned +about, so one control now does what two were doing. + +`forciblyIncludedUserIds` on the wire still carries the warned rows ticked back in, because +the send re-decides and would otherwise skip them. + +### Refusal + +A bulk request the api declines whole, naming the request **field** at fault and a stable +**code** rather than a sentence to display. Nothing is written. A **409** means the client's +table is stale; a **400** that a field of the request is wrong. Both arrive in the same +`errors[]`, so one client-side handler reads either and the payment-email wizard can put the +treasurer back on the step that owns the field. + ### Ask -One asking of one member to pay for one period — a payment request or a pre-notification. +One asking of one member to pay for one period — a contribution reminder or an incasso notification. A row each, not one per member and period: the treasurer chases, so a member can be asked in September, again in February and again the week after, and each is a thing that -happened. "Last asked" is the most recent of them. +happened. "Last sent" is the most recent of them, read per kind: a member moved onto direct debit +has been reminded and never pre-notified, and pooling the two would hide it. -An **incasso** is the direct debit itself; the record of a pre-notification is an +An **incasso** is the direct debit itself; the record of one of these is an `IncassoNotification`, which is the Dutch word the association uses for the mandate. ## Boards diff --git a/docs/flows/README.md b/docs/flows/README.md index a4d21746b..112503beb 100644 --- a/docs/flows/README.md +++ b/docs/flows/README.md @@ -17,7 +17,7 @@ tells you what the process is supposed to guarantee. | [Membership signup](membership-signup/README.md) | Account creation plus an application; the membership commits on whichever fact lands last | | [Recovery emails](recovery-emails/README.md) | The emails that let somebody into an account, and reading one before it is sent | | [Bulk contribution marking](bulk-contribution-marking/README.md) | Recording contributions for many users at once; a selection applies whole or not at all | -| [The fee cycle](fee-cycle/README.md) | Asking every unpaid member of a period for their contribution; one operation over the direct-debit partition | +| [Payment emails](payment-emails/README.md) | Asking a selection of members for what they owe; one send, routed per member into a reminder or an incasso notification | Account creation is the base flow and membership signup builds on it, so the two are read in that order and neither restates the other. diff --git a/docs/flows/bulk-contribution-marking/README.md b/docs/flows/bulk-contribution-marking/README.md index 2237da6a9..3fad63998 100644 --- a/docs/flows/bulk-contribution-marking/README.md +++ b/docs/flows/bulk-contribution-marking/README.md @@ -8,8 +8,8 @@ the counts reported back. Does not cover how a contribution period is created, how a single contribution is recorded from a user's own page, or asking members for what they owe — that is -[the fee cycle](../fee-cycle/README.md), which sends email and carries its own audit -rows. This flow only writes contribution records. +[payment emails](../payment-emails/README.md), which sends email and carries its own +audit rows. This flow only writes contribution records. ## Actors and entry points diff --git a/docs/flows/fee-cycle/README.md b/docs/flows/fee-cycle/README.md deleted file mode 100644 index a31e52e1a..000000000 --- a/docs/flows/fee-cycle/README.md +++ /dev/null @@ -1,254 +0,0 @@ -# The fee cycle - -## Scope - -Covers a treasurer asking every member of a contribution period who has not paid for -that year's contribution, in one operation: reading the cycle, reading one of the two -emails, and sending both statements from one confirmation. - -Does not cover recording a contribution as paid — that is -[bulk contribution marking](../bulk-contribution-marking/README.md), the continuous -half of the same job — nor creating a contribution period, nor the single-member -reminder sent from a row, which quotes the period's fee options rather than one amount. - -## Actors and entry points - -A treasurer or board member, from the user manager at `/user-manager`. They select a -contribution period, then pick **Open the fee cycle** from the bulk actions menu. - -The action needs a period and nothing else: unlike the paid and unpaid actions it is -not over a selection, so the row checkboxes have no bearing on it. Without a selected -period the menu entry is disabled, because there is no cycle without one. - -Nothing else enters this flow. There is no scheduled job and no external caller. - -## States - -The cycle is not a stored state machine. It is a question asked of the data, and a -member is in one of these positions when it is asked. - -```mermaid -stateDiagram-v2 - [*] --> NotInTheCycle: paid for the period - [*] --> Transfer: unpaid · no direct-debit flag - [*] --> DirectDebit: unpaid · direct-debit flag - [*] --> Excluded: unpaid · honorary or no email address - Transfer --> Transfer: asked again, another ask recorded - DirectDebit --> DirectDebit: notified again, another notification recorded - Transfer --> NotInTheCycle: contribution recorded - DirectDebit --> NotInTheCycle: contribution recorded -``` - -Which of `Transfer` and `DirectDebit` a member is in is the `incasso` flag on the -membership that put them in the period. It is not a choice the treasurer makes, which -is why not having the flag is not a warning. - -`Excluded` is not overridable. An honorary member owes nothing, a deleted account is nobody -to write to, and an address that is not on file cannot be written to; none of the three is a -judgement an operator can overrule. - -Nothing here forbids a repeat. The cycle can be run over a period as often as the treasurer -needs, and each run adds an ask rather than replacing the last. - -## Invariants - -Each of these is defended by a test named in the Testing section. - -- An honorary member is never emailed by the cycle, and cannot be included. -- A member with no email address is never emailed, and never counted as sent to. -- A deleted account is never emailed. -- A member who has paid for the period is never asked for it. -- The preview and the send never disagree about who is included, which side of the - partition they are on, or what they owe. Both read one plan. -- No email quotes an amount without stating the reason that amount applies. -- No amount is ever typed. Every amount is the period's fee for a chosen fee type. -- A sent statement never changes what it is recorded as having said. Both the amount and - the fee type are stored, so editing next year's fee cannot rewrite last year's email. -- A member on direct debit is never sent a payment request, and a member paying by - transfer is never sent a pre-notification. Sending the wrong one costs the member - money: a direct-debit member who transfers pays twice. -- A fee type naming a member the cycle does not write to is never silently ignored. -- An ask is never lost to a later one. A member can be asked for the same period as often - as the treasurer needs — twice in the same half of the year, or more — and each ask is its - own record with its own moment. -- A member's last-asked date is never read from the other side of the partition, and is - always the most recent of their asks on that side. -- A member is never partitioned by the flag on a membership that has ended, where they - hold one that has not. -- Reading an email writes no record and queues no send, and an email is never rendered - for a member the cycle will not write to. -- A deleted account is never written to. Deletion anonymises the address to a placeholder - and does not end the memberships, so it would otherwise still look like a member. - -## The journey - -```mermaid -flowchart TD - A[treasurer selects a period] --> B[bulk menu · Open the fee cycle] - B --> C[GET /contributions/fee-cycle] - C --> D[plan · every unpaid member of the period] - D --> E[table · partitioned, priced, with reasons] - E --> F[treasurer enters the due date and the debit date] - F --> G{read one email?} - G -- yes --> H[GET /contributions/fee-cycle/email-preview] - H --> I[the same EmailContent the send builds, rendered] - I --> E - G -- no --> J[Send] - J --> K[POST /contributions/fee-cycle/send] - K --> L{fee types all name recipients?} - L -- no --> M[409 · NonRecipientFeeTypeUserIds] - L -- yes --> N[plan read again · one record per recipient] - N --> O[one email job per recipient] - O --> P[200 · queued per side, and excluded] - P --> Q[frontend reloads the period] -``` - -1. The treasurer picks a period and opens the cycle. -2. The api plans it: every member whose membership overlapped the period, minus those - with a contribution recorded for it, each one partitioned by the direct-debit flag on - the membership judged for them — their active one where they hold one — and priced by - the fee type that applies. -3. The table shows the partition with counts per group, each member's fee type and the - amount it prices, and — for a member already asked — the date they were last asked - on their own side, with a count of them above the table. -4. The treasurer enters a payment due date for the transfer group and a debit date for - the direct-debit group. Both must be in the future. -5. They may change a member's fee type. The amount re-renders from the period without a - round trip; there is no field for an amount. -6. They may read one member's email. Which of the two statements comes back is that - member's own side of the partition, and it is built by the builder the send uses. -7. Sending re-reads the plan, writes one record per recipient — a new one each run — and - queues one email each. -8. The result reports each side separately, and how many members were excluded. - -## Alternative orderings - -The plan is read twice — once for the table and once by the send — so the data can -move in between. - -```mermaid -flowchart TD - A[cycle read · member 42 unpaid, on transfer] --> B[42 marked paid elsewhere] - B --> C[treasurer sends] - C --> D{was a fee type set for 42?} - D -- yes --> E[409 · NonRecipientFeeTypeUserIds names 42] - E --> F[the cycle is re-read and 42 is gone] - D -- no --> G[42 is simply not in the plan the send reads] - G --> H[200 · 42 is not written to and not counted] -``` - -A member who left the cycle without the treasurer having stated anything about them is -not an error: the send is about a period, not about a list of ids, so the answer is -simply the newer one. A member the treasurer *did* state a fee type for is different — -that statement now names somebody the send will not write to, and applying the rest -would leave the treasurer believing they had changed a fee they had not. - -The send runs in one transaction, so a change arriving after it begins is not a -distinct ordering. - -## Credentials - -This flow issues nothing. It is authorised by the caller's existing session and needs -write permission on both `ContributionReminder` and `IncassoNotification`, which the -board role carries. No token is minted, transmitted out of band, or retired here. - -The preview endpoint renders an email that carries the recipient's name and address, so -it is gated identically to sending one. - -## Endpoints - -| | | -|---|---| -| Path | `GET /contributions/fee-cycle?contributionPeriodId=` | -| Authorisation | write on `ContributionReminder` **and** `IncassoNotification` | -| Response 200 | `{contributionPeriodId, rows[]}` | -| Row | `{userId, name, memberType, memberSince, group, disposition, reason, feeType, amount, lastAskedOn}` | - -| | | -|---|---| -| Path | `POST /contributions/fee-cycle/send` | -| Authorisation | as above | -| Request | `{contributionPeriodId, paymentDueDate, debitDate, feeTypeOverrides}` | -| Response 200 | `{paymentRequestsQueued, preNotificationsQueued, excluded}` | -| Response 409 | `ProblemDetail` with `errors[]`, code `NonRecipientFeeTypeUserIds`, `values` naming the ids | - -| | | -|---|---| -| Path | `GET /contributions/fee-cycle/email-preview?contributionPeriodId=&userId=&paymentDueDate=&debitDate=&feeType=` | -| Authorisation | as above | -| Response 200 | `{group, feeType, subject, html, recipientEmail, recipientName}` | -| Response 404 | the member is not in this period's cycle, or the cycle sends them nothing | - -`group` is `TRANSFER` or `DIRECT_DEBIT`. `feeType` is `FULL_YEAR_FEE`, `HALF_YEAR_FEE` -or `ALUMNI_FEE`; `feeType` on the preview request is optional and defaults to the one -that applies. `disposition` and `reason` come from the shared bulk vocabulary; the -cycle sets only `INCLUDED`, and `EXCLUDED` with `HONORARY` or `NO_EMAIL`. - -Neither send endpoint takes a list of members. Sending is one request whatever the size -of the cycle. - -## Failure and recovery - -**A fee type names somebody the cycle no longer writes to.** 409 with -`NonRecipientFeeTypeUserIds` and the ids. Nothing was sent. The dialog re-reads the -cycle and names the members at fault, and the treasurer sends again. - -**A member paid between the read and the send.** They are not in the plan the send -reads, so they are not written to and not counted. No refusal, because nothing the -treasurer stated was about them. - -**A date is missing or not in the future.** The form refuses before the request, and -the api rejects a null with a validation error. Reading an email is unavailable until -both dates are given, because both reach the email. - -**The cycle cannot be read.** The dialog says so and shows no rows, so an empty table -is never mistaken for a period with nobody left to ask. - -**Sending twice, or five times.** Each send writes its own ask and queues its own email. -Re-sending is allowed as often as the treasurer needs — the same period, the same half of -the year, the same week — because chasing is the job; it is warned about rather than -blocked. The dialog counts the already-asked members above the table and colours their -date, and making them rows to tick back in would mean a hundred ticks in the second cycle -of the year. - -`contribution_reminders` and `incasso_notifications` therefore hold a row per ask rather -than per member and period, and the preview reads the most recent of a member's asks. - -**An email job fails.** The record was written before the send was queued, so a failed -delivery leaves a record and the job in the outbox rather than silently nothing. - -**The client loses its state mid-action.** Nothing is held client-side but the two -dates and any changed fee types. Reopening the dialog re-reads the cycle. - -## Where the code lives - -| Concern | File | -|---|---| -| Endpoints | `services/api/.../contribution/web/FeeCycleController.kt` | -| Who is in the cycle, and what they owe | `services/api/.../contribution/domain/FeeCyclePlanner.kt` | -| The plan model | `services/api/.../contribution/domain/FeeCycle.kt` | -| Sending, and refusing a stray fee type | `services/api/.../contribution/domain/FeeCycleUseCases.kt` | -| Fee type and amount from the period | `services/api/.../contribution/domain/FeeResolution.kt` | -| The payment request | `services/api/.../contribution/domain/ContributionReminderEmailBuilder.kt` | -| The pre-notification | `services/api/.../contribution/domain/IncassoNotificationEmailBuilder.kt` | -| Rendering one for reading | `services/api/.../contribution/domain/FeeCycleEmailPreviewService.kt` | -| The two records, one row per ask | `services/api/.../contribution/persistence/{ContributionReminder,IncassoNotification}.kt` | -| The two dates, as one value | `services/api/.../contribution/domain/FeeCycle.kt` | -| The dialog | `services/frontend/src/components/common/modals/bulk/FeeCycleDialog.vue` | -| Rows, and only the changed fee types | `services/frontend/src/utils/feeCycle.ts` | - -## Testing - -| Suite | Covers | -|---|---| -| `FeeCyclePlannerTest` | Who is in the cycle: the partition, both exclusions, paid members absent, the judged membership, last-asked per side | -| `FeeCycleUseCasesTest` | The send: both sides written and reported separately, restating a record, refusing a stray fee type | -| `FeeResolutionTest` | The cutoff boundary in both directions, and that the cutoff comes from the period | -| `ContributionReminderEmailBuilderTest`, `IncassoNotificationEmailBuilderTest` | The rendered bodies: the amount, the reason, the date, and that the pre-notification asks for no transfer | -| `FeeCycleEmailPreviewServiceTest` | Each side reads its own statement, an override is quoted, and the render goes through the shared renderer | -| `FeeCycleControllerIT` | All three endpoints end to end, the 409 body, the authorisation, and that the send writes to exactly the members the preview named | -| `FeeCycle.test.ts`, `feeCycle.test.ts` | The dialog: both sides shown and counted, live re-pricing, only changed fee types sent, a refusal reported rather than closed on, the recipient chooser | - -The preview-and-send agreement is asserted directly: `FeeCycleControllerIT` reads the -preview, derives the expected recipients per side from its rows, then asserts the send -wrote exactly those. diff --git a/docs/flows/payment-emails/README.md b/docs/flows/payment-emails/README.md new file mode 100644 index 000000000..4d58e51d1 --- /dev/null +++ b/docs/flows/payment-emails/README.md @@ -0,0 +1,346 @@ +# Payment emails + +## Scope + +Covers a treasurer asking a selection of members for what they owe for one contribution +period: choosing who the batch writes to, what each of them gets, reading one of the emails, +and sending them all from one confirmation. + +Does not cover recording a contribution as paid — that is +[bulk contribution marking](../bulk-contribution-marking/README.md), the continuous half of +the same job — nor creating a contribution period, nor the single-member reminder sent from +a row, which quotes the period's fee options rather than one amount. + +## Actors and entry points + +A treasurer or board member, from the user manager at `/user-manager`. They select a +contribution period, tick rows with the row checkboxes, then pick **Send payment emails** +from the bulk actions menu. Without both a period and a selection the menu entry is +disabled: there is nothing to bill for, or nobody to bill. + +Nothing else enters this flow. There is no scheduled job and no external caller. + +## States + +Not a stored state machine. A member is in one of these positions when the question is +asked of the data, and every position is visible on the first step. + +```mermaid +stateDiagram-v2 + [*] --> Reminder: ticked · no direct-debit flag + [*] --> Notification: ticked · direct-debit flag + [*] --> Warned: already paid, or not a member in the period + [*] --> Excluded: honorary, deleted, or no email address + Reminder --> Notification: treasurer switches the row + Notification --> Reminder: treasurer switches the row + Reminder --> Untouched: treasurer unticks the row + Notification --> Untouched: treasurer unticks the row + Warned --> Reminder: ticked back in + Warned --> Notification: ticked back in + Reminder --> Reminder: sent again, another ask recorded + Notification --> Notification: sent again, another ask recorded +``` + +`Excluded` is not overridable. An honorary member owes nothing, a deleted account is nobody +to write to, and an address that is not on file cannot be written to; none of the three is a +judgement an operator can overrule, and a tick box on such a row would be a promise the tool +cannot keep. + +`Warned` is a default, not a rule. A contribution recorded in error and a membership +backdated by hand are both real, so the operator can tick either row back in. + +Which of `Reminder` and `Notification` a member starts in is the `incasso` flag on the +membership judged for them. That is a default too: a mandate that failed this morning is +chased by transfer, and no flag knows that yet. + +## Invariants + +Each of these is defended by a test named in the Testing section. + +- An honorary member is never emailed, and cannot be ticked back in. +- A member with no email address is never emailed, and cannot be ticked back in. +- A deleted account is never emailed, and cannot be ticked back in. +- A member who has already paid is never emailed unless the operator says so explicitly. +- The Send-to box is the selection. A member the box is not ticked for is never written to, + whatever the api would have decided about them. +- The table and the send never disagree about who is written to, which email they get, or + what they owe. Both read one plan. +- No email quotes an amount without stating the reason that amount applies. +- No amount is ever typed. Every amount is the period's fee for a chosen fee type. +- A sent email never changes what it is recorded as having said. Both the amount and the fee + type are stored, so editing next year's fee cannot rewrite last year's email. +- A member is never sent both emails by one send. +- A fee type, or a chosen email, naming a member the send does not write to is never + silently ignored. +- An id in the selection that is not a user is refused, never dropped into a count. +- A selection naming the same member twice is refused, never collapsed. +- A member ticked back in is refused when the send still would not write to them, and when + the selection does not name them at all. +- No email promises a date that has already passed, and none promises one before the period + starts or more than three months after it ends. Both rules hold whether the request came + from the wizard or from anything else. +- Every refusal names the request field it is about, so the wizard can land the treasurer on + the step that owns it with the rows or the input marked. +- A refused send writes nothing. There is no half-sent batch. +- An ask is never lost to a later one. A member can be asked as often as the treasurer + needs, and each ask is its own record with its own moment. +- A member's last-sent date is never read from the other email, and is always the most + recent of their asks of that kind. +- A member is never judged by the flag on a membership that has ended, where they hold one + that has not. +- An email is never announced without the date it promises: a payment due date is required + exactly when somebody is being asked to transfer, a debit date exactly when somebody is + being told when the money moves. +- Reading an email writes no record and queues no send, and an email is never rendered for a + member the send will not write to. +- Nothing is ever sent from a step alone. The confirmation stands between Send and the + request, and backing out of it sends nothing. + +## The journey + +The send is three steps, each asking one question, then a confirmation. The stepper header +is clickable back to any step already reached, and every choice survives moving between them. + +```mermaid +flowchart TD + A[treasurer picks a period and ticks rows] --> B[bulk menu · Send payment emails] + B --> C[POST /contributions/bulk/email/preview] + C --> D[plan · one row per selected member] + D --> E[step 1 · Members: a Send-to box per row, with reasons] + E --> F[step 2 · Fees & emails: the ticked members only] + F --> G[step 3 · What will be sent: both dates, then a block per recipient] + G --> H{read one member's email?} + H -- yes --> I[GET /contributions/bulk/email/message] + I --> G + H -- no --> J[Send] + J --> K[confirmation · counts, dates, every override] + K -- Back --> G + K -- Send --> L[POST /contributions/bulk/email/send] + L --> M{refused?} + M -- 409 --> N[plan re-read · back to the step that owns the field] + M -- 400 --> O[back to the step that owns the field, input flagged] + M -- no --> P[plan read again · one record per recipient] + P --> Q[one email job per recipient] + Q --> R[200 · counted per kind] + R --> S[frontend clears the selection and reloads the period] +``` + +1. The treasurer picks a period, ticks the members, and opens the action. +2. The api plans it: one row per selected member, each partitioned by the direct-debit flag + on the membership judged for them — their active one where they hold one — and priced by + the fee type that applies. +3. **Step 1, Members.** One Send-to box per row, and that box is the selection. Members the + api would write to start ticked, members it warns about start unticked, and members it + cannot write to have no box. A reason sits on every row that is warned about or cannot be + emailed. Unticking everybody stops the wizard here. +4. **Step 2, Fees & emails.** Only the members still ticked. Each row states which email that + member gets and which fee prices it, both changeable, with the amount and the date they + were last sent that same email. Changing either raises a banner above the table naming the + members and saying what the change means — separately for the two, because the wrong email + can make a member pay twice while the wrong fee bills the wrong amount. +5. **Step 3, What will be sent.** The payment due date and the debit date, then a block per + recipient: their name, the email they get, the fee type and the amount, and a Preview that + renders that member's actual email. A date nobody in the batch needs is optional and says + why. +6. Send does not send. It opens a confirmation: chips for how many of each email, the dates + they carry and how many of the selected members are left alone, then the overrides grouped + into one warning — ticked back in, switched, charged another fee type, already sent this + before. Back returns to step 3 with everything intact. +7. Confirming re-reads the plan, writes one record per recipient — a new one each time — and + queues one email each. +8. The result reports each kind separately, and how many were not written to. + +## Refusal routing + +Every refusal carries the request field it is about, and the wizard routes on that field +alone. The confirmation closes on any refusal; a 409 re-reads the plan first, because it +means the plan has moved. Re-reading keeps every tick, fee type and email kind whose member +is still in the new plan and still reachable — a member the refusal named loses theirs, +because the plan has just contradicted it. + +| Field | Step | Codes | +|---|---|---| +| `userIds` | 1 — the rows it named are marked | `UnknownUserIds`, `DuplicateUserIds` | +| `forciblyIncludedUserIds` | 1 — the rows it named are marked | `NonRecipientForcedUserIds`, `UnknownForcedUserIds`, `Size` | +| `kindOverrides` | 2 | `NonRecipientEmailKindUserIds`, `Size` | +| `feeTypeOverrides` | 2 | `NonRecipientFeeTypeUserIds`, `Size` | +| `paymentDueDate`, `debitDate` | 3 — the input itself is flagged | `DateRequired`, `DateOutsideContributionPeriod`, `Future` | + +A refusal naming more than one field lands on the earliest step of them, because correcting +that is what the later ones are read against. + +Per ADR-026 the sentence the operator reads for a new code is composed in the browser from +the code, not taken from the api's `message`, and the api's `message` for those codes is +fixed per code and interpolates nothing. The older codes keep the message the api composes +for them; rewriting those would touch four other dialogs for no benefit here. + +## Alternative orderings + +The plan is read twice — once for the table and once by the send — so the data can move in +between. + +```mermaid +flowchart TD + A[step 1 read · member 42 unpaid and ticked] --> B[42 marked paid elsewhere] + B --> C[treasurer sends] + C --> D{was 42 ticked back in, or a fee or email stated for them?} + D -- yes --> E[409 · the refusal names 42] + E --> F[the plan is re-read and 42 is now warned] + D -- no --> G[42 is warned in the plan the send reads] + G --> H[200 · 42 is not written to and is counted as such] +``` + +A member who became warned without the treasurer having stated anything about them is not an +error: the send re-decides, and the answer is simply the newer one. A member the treasurer +*did* state something for is different — that statement now names somebody the send will not +write to, and applying the rest would leave the treasurer believing they had changed +something they had not. + +The send runs in one transaction, so a change arriving after it begins is not a distinct +ordering. + +## Credentials + +This flow issues nothing. It is authorised by the caller's existing session and needs write +permission on both `ContributionReminder` and `IncassoNotification`, which the board role +carries. Both, on every endpoint, because one send writes both records. No token is minted, +transmitted out of band, or retired here. + +The message endpoint renders an email carrying the recipient's name and address, so it is +gated identically to sending one. + +## Endpoints + +| | | +|---|---| +| Path | `POST /contributions/bulk/email/preview` | +| Authorisation | write on `ContributionReminder` **and** `IncassoNotification` | +| Request | `{contributionPeriodId, userIds[]}` — 1 to 1000 ids, all positive | +| Response 200 | `{contributionPeriodId, rows[]}` | +| Row | `{userId, name, memberType, memberSince, disposition, reason, defaultKind, feeType, amount, lastRemindedOn, lastNotifiedOn}` | + +| | | +|---|---| +| Path | `POST /contributions/bulk/email/send` | +| Authorisation | as above | +| Request | `{contributionPeriodId, userIds[], forciblyIncludedUserIds[], kindOverrides, paymentDueDate, debitDate, feeTypeOverrides}` | +| Response 200 | `{remindersSent, incassoNotificationsSent, notWrittenTo}` | +| Response 400 | `ProblemDetail` with `errors[]` of `{objectName, field, message, code}` — a bean constraint, or a date rule that needed the period | +| Response 409 | the same `errors[]`, plus `values` naming the offending ids — a selection that no longer matches the data | + +| | | +|---|---| +| Path | `GET /contributions/bulk/email/message?kind=&contributionPeriodId=&userId=&date=&feeType=` | +| Authorisation | as above | +| Response 200 | `{kind, feeType, subject, html, recipientEmail, recipientName}` | +| Response 404 | the member could not be read, or this send writes nothing to them | + +`kind` is `REMINDER` or `INCASSO_NOTIFICATION`; on the message request it is whichever the +row is currently set to, so a switched row previews what it will actually get. `feeType` is +`FULL_YEAR_FEE`, `HALF_YEAR_FEE` or `ALUMNI_FEE`, and is optional on the message request, +defaulting to the one that applies. `disposition` and `reason` come from the shared bulk +vocabulary; this flow sets `INCLUDED`, `WARNING` with `ALREADY_PAID` or +`NOT_MEMBER_IN_PERIOD`, and `EXCLUDED` with `HONORARY`, `DELETED` or `NO_EMAIL`. + +Both dates are optional on the wire and required by what the batch turns out to send, which +is why a missing one is refused above the web layer rather than by the request shape. + +`notWrittenTo` reads 0 for a wizard-driven send, because the wizard sends only the ticked +rows. It stays for direct callers, which may name members the send skips. + +## Failure and recovery + +**A statement names somebody the send no longer writes to.** 409 naming the ids. Nothing was +sent. The wizard re-reads the plan, returns to the step that owns the field, and marks the +rows at fault. + +**An id in the selection is not a user, or is named twice.** 409 against `userIds`. Back to +step 1 with those rows marked. Both mean the selection the client holds and the one the api +holds have parted company, so the plan is re-read before the marks are shown. The read names +such an id too, in `unknownUserIds`, so the counts add up to the selection before Send rather +than after it. + +**A member ticked back in is one the send still will not write to.** 409 against +`forciblyIncludedUserIds`. Back to step 1. The wizard cannot produce this itself — a +hard-excluded member has no box — so it means the member became unreachable after the plan +was read. + +**A date is missing, has passed, or falls outside the period.** 400 against that date's own +field. The wizard returns to step 3 and turns that input red, and the message clears as soon +as the date is changed. The browser enforces the same three rules before the request, so this +is the direct caller's path and the browser's safety net. + +**A member became warned between the read and the send.** They are not written to and are +counted among those that were not. No refusal, because nothing the treasurer stated was +about them. + +**A date nobody needs is missing.** Nothing happens. The field says why it is empty, and the +send does not ask for it. + +**The plan cannot be read.** The wizard says so and shows no rows, so an empty table is +never mistaken for a selection with nobody left to ask. + +**Sending twice, or five times.** Each send writes its own ask and queues its own email. +Re-sending is allowed as often as the treasurer needs — chasing is the job — so +`contribution_reminders` and `incasso_notifications` hold a row per ask rather than per +member and period, and the table reads the most recent of them. + +**An email job fails.** The record was written before the send was queued, so a failed +delivery leaves a record and the job in the outbox rather than silently nothing. + +**The operator changes their mind at the confirmation.** Back returns to step 3 with every +choice intact. Nothing was sent, because nothing is sent until the confirmation is answered. + +**The client loses its state mid-action.** Nothing is held client-side but the ticks, the two +dates and any switched rows or changed fee types. Reopening the wizard re-reads the plan. + +## Where the code lives + +| Concern | File | +|---|---| +| Endpoints | `services/api/.../contribution/web/BulkContributionEmailController.kt` | +| Request DTOs and their bean constraints | `services/api/.../contribution/web/BulkContributionEmailRequest.kt` | +| Who is written to, with which email, and what they owe | `services/api/.../contribution/domain/ContributionEmailPlanner.kt` | +| The plan model | `services/api/.../contribution/domain/ContributionEmail.kt` | +| Sending, and every refusal it makes | `services/api/.../contribution/domain/BulkContributionEmailUseCases.kt` | +| Fee type and amount from the period | `services/api/.../contribution/domain/FeeResolution.kt` | +| The contribution reminder | `services/api/.../contribution/domain/ContributionReminderEmailBuilder.kt` | +| The incasso notification | `services/api/.../contribution/domain/IncassoNotificationEmailBuilder.kt` | +| Rendering one for reading | `services/api/.../contribution/domain/ContributionEmailMessageService.kt` | +| The two records, one row per ask | `services/api/.../contribution/persistence/{ContributionReminder,IncassoNotification}.kt` | +| Refusal shapes, 409 and 400 | `services/api/.../shared/dto/bulk/{BulkSelectionRejected,BulkFieldRejected}.kt` | +| The wizard, and where a refusal lands | `services/frontend/src/components/common/modals/bulk/paymentEmail/PaymentEmailWizard.vue` | +| The three steps | `services/frontend/src/components/common/modals/bulk/paymentEmail/PaymentEmail{Members,Fees,Review}Step.vue` | +| Routing, counting, the period-bounds mirror, and only what changed | `services/frontend/src/utils/contributionEmail.ts` | +| Reading a refusal out of either status | `services/frontend/src/utils/bulkRejection.ts` | + +## Testing + +| Suite | Covers | +|---|---| +| `ContributionEmailPlannerTest` | The plan: routing, all three hard exclusions, both warnings, pricing, the judged membership, last-sent per kind | +| `BulkContributionEmailUseCasesTest` | The send: both kinds written and counted separately, switching, ticking back in, every refusal predicate, and the period-bounds check | +| `FeeResolutionTest` | The cutoff boundary in both directions, and that the cutoff comes from the period | +| `ContributionReminderEmailBuilderTest`, `IncassoNotificationEmailBuilderTest` | The rendered bodies: the amount, the reason, the date, and that the notification asks for no transfer | +| `ContributionEmailMessageServiceTest` | Each kind renders its own email, a switched row reads the one it will get, an override is quoted, and the render goes through the shared renderer | +| `BulkContributionEmailControllerIT` | All three endpoints end to end, every refusal's status, `field` and `code`, the authorisation, and that the send writes to exactly the members the plan named | +| `bulkRejection.test.ts` | Both refusal statuses read into one shape, and which codes the browser writes the sentence for | +| `contributionEmail.test.ts` | The pure helpers, and the browser's copy of the period-bounds rule | +| `PaymentEmailWizard.test.ts` | The wizard: ticking and unticking, both change banners, state surviving navigation, the request body that comes out, and a refusal on each field group landing on the right step | +| `user-manager-payment-emails.spec.ts` | The journey in a browser against mocks, including a backend 400 turning a date input red | +| `payment-emails.feature` | What a member receives and what the record shows, against the running stack | + +The acceptance feature asserts what the association guarantees — the email that arrived, the +amount and reason it states, and the asks recorded afterwards. Which status a refusal answers +and which field it names are `BulkContributionEmailControllerIT`'s, per +`docs/adr/testing/ADR-001`: a system test earns its place only when the assertion needs the +real stack, and a status code does not. + +The table-and-send agreement is asserted directly: `BulkContributionEmailControllerIT` reads +the plan, derives the included members from its rows, then asserts the send wrote to exactly +those. Scenario names in the feature file are mirrored by the integration test names, so the +correspondence can be checked by eye. + +The field names in the refusal routing table are a contract between the api and the wizard. +`BulkContributionEmailControllerIT` asserts each of them over HTTP, which is what stops the +routing breaking silently when a field is renamed. diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml index 95e1643be..c743440c3 100644 --- a/services/api/openapi.yaml +++ b/services/api/openapi.yaml @@ -483,6 +483,98 @@ components: - queued - skipped type: object + BulkContributionEmailPreviewRequest: + properties: + contributionPeriodId: + format: int64 + minimum: 0 + type: + - integer + - 'null' + userIds: + items: + format: int64 + type: integer + maxItems: 1000 + minItems: 0 + type: array + required: + - contributionPeriodId + - userIds + type: object + BulkContributionEmailPreviewResponse: + properties: + contributionPeriodId: + format: int64 + type: integer + rows: + items: + $ref: '#/components/schemas/BulkContributionEmailRowResponse' + type: array + unknownUserIds: + description: Selected ids that resolve to nobody. No row is drawn, and the send refuses them. + items: + format: int64 + type: integer + type: array + required: + - contributionPeriodId + - rows + - unknownUserIds + type: object + BulkContributionEmailRowResponse: + properties: + amount: + description: Follows from the fee type and the period. Never typed. + format: double + type: + - number + - 'null' + defaultKind: + $ref: '#/components/schemas/ContributionEmailKind' + description: The email this member gets unless the treasurer switches it. + disposition: + $ref: '#/components/schemas/BulkRowDisposition' + feeType: + description: Absent only for honorary members, who owe nothing. + oneOf: + - $ref: '#/components/schemas/BulkFeeType' + - type: 'null' + lastNotifiedOn: + format: date + type: + - string + - 'null' + lastRemindedOn: + format: date + type: + - string + - 'null' + memberSince: + description: Start of the membership every decision on this row was judged against. + format: date + type: + - string + - 'null' + memberType: + $ref: '#/components/schemas/MemberType' + name: + type: string + reason: + description: Why this member is warned about or not written to. + oneOf: + - $ref: '#/components/schemas/BulkRowReason' + - type: 'null' + userId: + format: int64 + type: integer + required: + - defaultKind + - disposition + - memberType + - name + - userId + type: object BulkFeeType: enum: - FULL_YEAR_FEE @@ -602,6 +694,7 @@ components: - HONORARY - INCASSO_MISMATCH - NO_ACTIVE_MEMBERSHIP + - NOT_MEMBER_IN_PERIOD - STARTED_TODAY - NO_EMAIL - DELETED @@ -615,15 +708,12 @@ components: properties: disposition: $ref: '#/components/schemas/BulkRowDisposition' - feeCycleGroup: - $ref: '#/components/schemas/FeeCycleGroup' feeType: $ref: '#/components/schemas/BulkFeeType' reason: $ref: '#/components/schemas/BulkRowReason' required: - disposition - - feeCycleGroup - feeType - reason BulkTargetMoveResult: @@ -1010,6 +1100,33 @@ components: enum: - BREVO type: string + ContributionEmailKind: + enum: + - REMINDER + - INCASSO_NOTIFICATION + type: string + ContributionEmailMessageResponse: + properties: + feeType: + $ref: '#/components/schemas/BulkFeeType' + html: + type: string + kind: + $ref: '#/components/schemas/ContributionEmailKind' + recipientEmail: + type: string + recipientName: + type: string + subject: + type: string + required: + - feeType + - html + - kind + - recipientEmail + - recipientName + - subject + type: object ContributionPeriodResponse: properties: alumniFee: @@ -1996,114 +2113,6 @@ components: - label - message type: object - FeeCycleEmailPreviewResponse: - properties: - feeType: - $ref: '#/components/schemas/BulkFeeType' - description: The fee type the email states, and the reason it gives. - group: - $ref: '#/components/schemas/FeeCycleGroup' - description: Which statement this member receives, decided by their direct-debit flag. - html: - type: string - recipientEmail: - type: string - recipientName: - type: string - subject: - type: string - required: - - feeType - - group - - html - - recipientEmail - - recipientName - - subject - type: object - FeeCycleGroup: - enum: - - DIRECT_DEBIT - - TRANSFER - type: string - FeeCyclePreviewResponse: - properties: - contributionPeriodId: - format: int64 - type: integer - rows: - items: - $ref: '#/components/schemas/FeeCycleRowResponse' - type: array - required: - - contributionPeriodId - - rows - type: object - FeeCycleResultResponse: - properties: - excluded: - description: Members in the cycle who were not written to. - format: int32 - type: integer - paymentRequestsQueued: - format: int32 - type: integer - preNotificationsQueued: - format: int32 - type: integer - required: - - excluded - - paymentRequestsQueued - - preNotificationsQueued - type: object - FeeCycleRowResponse: - properties: - amount: - description: Follows from the fee type and the period. Never typed. - format: double - type: - - number - - 'null' - disposition: - $ref: '#/components/schemas/BulkRowDisposition' - feeType: - description: Absent only for honorary members, who owe nothing. - oneOf: - - $ref: '#/components/schemas/BulkFeeType' - - type: 'null' - group: - $ref: '#/components/schemas/FeeCycleGroup' - description: Decided by the member's direct-debit flag, not by the operator. - lastAskedOn: - description: When this member was last asked for this period, on this side of the partition. - format: date - type: - - string - - 'null' - memberSince: - description: Start of the membership every decision on this row was judged against. - format: date - type: - - string - - 'null' - memberType: - $ref: '#/components/schemas/MemberType' - name: - type: string - reason: - description: Why this member is not written to. Absent on an included row. - oneOf: - - $ref: '#/components/schemas/BulkRowReason' - - type: 'null' - userId: - format: int64 - type: integer - required: - - disposition - - group - - memberType - - name - - userId - type: object FieldTeamRequest: description: Field a team in a game in a season, with or without the line-up it last had properties: @@ -3096,6 +3105,23 @@ components: - password - token type: object + PaymentEmailsResultResponse: + properties: + incassoNotificationsSent: + format: int32 + type: integer + notWrittenTo: + description: Members in the selection who were not written to. + format: int32 + type: integer + remindersSent: + format: int32 + type: integer + required: + - incassoNotificationsSent + - notWrittenTo + - remindersSent + type: object PendingActivation: description: The activation email an account that has not been activated takes. properties: @@ -3419,7 +3445,7 @@ components: - played - startDate type: object - SendFeeCycleRequest: + SendPaymentEmailsRequest: properties: contributionPeriodId: format: int64 @@ -3428,7 +3454,7 @@ components: - integer - 'null' debitDate: - description: The date the direct-debit group is told the money will be taken. + description: Required exactly when somebody here is getting a pre-notification. format: date type: - string @@ -3436,19 +3462,40 @@ components: feeTypeOverrides: additionalProperties: $ref: '#/components/schemas/BulkFeeType' - description: Fee type per member, where the treasurer changed it from the one that applies. + description: Fee type per member, where the treasurer changed it. + type: object + forciblyIncludedUserIds: + description: Warned members this request overrules. Every one of them must be in the selection and must be somebody the send writes to. + items: + format: int64 + type: integer + maxItems: 1000 + minItems: 0 + type: array + kindOverrides: + additionalProperties: + $ref: '#/components/schemas/ContributionEmailKind' + description: Members moved off the email their direct-debit flag chose. type: object paymentDueDate: - description: The date the transfer group is asked to have paid by. + description: Required exactly when somebody here is getting a payment request. format: date type: - string - 'null' + userIds: + items: + format: int64 + type: integer + maxItems: 1000 + minItems: 0 + type: array required: - contributionPeriodId - - debitDate - feeTypeOverrides - - paymentDueDate + - forciblyIncludedUserIds + - kindOverrides + - userIds type: object SentEmailPreview: properties: @@ -8701,21 +8748,44 @@ paths: description: Server error tags: - Contributions - /contributions/bulk/mark-paid: - post: - operationId: markPaid - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/BulkMarkPaidRequest' + /contributions/bulk/email/message: + get: + operationId: readContributionEmail + parameters: + - in: query + name: kind required: true + schema: + $ref: '#/components/schemas/ContributionEmailKind' + - in: query + name: contributionPeriodId + required: true + schema: + format: int64 + type: integer + - in: query + name: userId + required: true + schema: + format: int64 + type: integer + - in: query + name: date + required: true + schema: + format: date + type: string + - in: query + name: feeType + required: false + schema: + $ref: '#/components/schemas/BulkFeeType' responses: '200': content: application/json: schema: - $ref: '#/components/schemas/BulkActionResult' + $ref: '#/components/schemas/ContributionEmailMessageResponse' description: OK '400': content: @@ -8808,21 +8878,21 @@ paths: description: Server error tags: - Contributions - /contributions/bulk/mark-unpaid: + /contributions/bulk/email/preview: post: - operationId: markUnpaid + operationId: previewBulkContributionEmail requestBody: content: application/json: schema: - $ref: '#/components/schemas/BulkMarkUnpaidRequest' + $ref: '#/components/schemas/BulkContributionEmailPreviewRequest' required: true responses: '200': content: application/json: schema: - $ref: '#/components/schemas/BulkActionResult' + $ref: '#/components/schemas/BulkContributionEmailPreviewResponse' description: OK '400': content: @@ -8915,22 +8985,21 @@ paths: description: Server error tags: - Contributions - /contributions/fee-cycle: - get: - operationId: previewFeeCycle - parameters: - - in: query - name: contributionPeriodId + /contributions/bulk/email/send: + post: + operationId: sendPaymentEmails + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SendPaymentEmailsRequest' required: true - schema: - format: int64 - type: integer responses: '200': content: application/json: schema: - $ref: '#/components/schemas/FeeCyclePreviewResponse' + $ref: '#/components/schemas/PaymentEmailsResultResponse' description: OK '400': content: @@ -9023,45 +9092,21 @@ paths: description: Server error tags: - Contributions - /contributions/fee-cycle/email-preview: - get: - operationId: previewFeeCycleEmail - parameters: - - in: query - name: contributionPeriodId - required: true - schema: - format: int64 - type: integer - - in: query - name: userId - required: true - schema: - format: int64 - type: integer - - in: query - name: paymentDueDate - required: true - schema: - format: date - type: string - - in: query - name: debitDate + /contributions/bulk/mark-paid: + post: + operationId: markPaid + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/BulkMarkPaidRequest' required: true - schema: - format: date - type: string - - in: query - name: feeType - required: false - schema: - $ref: '#/components/schemas/BulkFeeType' responses: '200': content: application/json: schema: - $ref: '#/components/schemas/FeeCycleEmailPreviewResponse' + $ref: '#/components/schemas/BulkActionResult' description: OK '400': content: @@ -9154,21 +9199,21 @@ paths: description: Server error tags: - Contributions - /contributions/fee-cycle/send: + /contributions/bulk/mark-unpaid: post: - operationId: sendFeeCycle + operationId: markUnpaid requestBody: content: application/json: schema: - $ref: '#/components/schemas/SendFeeCycleRequest' + $ref: '#/components/schemas/BulkMarkUnpaidRequest' required: true responses: '200': content: application/json: schema: - $ref: '#/components/schemas/FeeCycleResultResponse' + $ref: '#/components/schemas/BulkActionResult' description: OK '400': content: diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailControllerIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailControllerIT.kt new file mode 100644 index 000000000..cdc3e9c5f --- /dev/null +++ b/services/api/src/integrationTest/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailControllerIT.kt @@ -0,0 +1,739 @@ +package net.blueshell.api.contribution.web + +import net.blueshell.api.contribution.domain.ContributionEmailKind +import net.blueshell.api.contribution.persistence.Contribution +import net.blueshell.api.contribution.persistence.ContributionPeriod +import net.blueshell.api.contribution.persistence.ContributionReminder +import net.blueshell.api.contribution.persistence.ContributionReminderRepository +import net.blueshell.api.contribution.persistence.IncassoNotificationRepository +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.enums.MemberType +import net.blueshell.api.shared.enums.Role +import net.blueshell.api.testsupport.UserTestSupport +import net.blueshell.api.user.persistence.User +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import tools.jackson.databind.ObjectMapper +import java.time.Instant +import java.time.LocalDate + +/** The payment emails end to end, and that the table and the send agree. */ +@SpringBootTest +class BulkContributionEmailControllerIT : UserTestSupport() { + + private companion object { + /** An id no user has, for the refusals that are about the selection being stale. */ + const val GONE = 9_999_999L + } + + @Autowired + private lateinit var reminderRepository: ContributionReminderRepository + + @Autowired + private lateinit var preNotificationRepository: IncassoNotificationRepository + + @Autowired + private lateinit var jsonMapper: ObjectMapper + + private val periodStart = LocalDate.now().minusMonths(2) + private val periodEnd = LocalDate.now().plusMonths(6) + private val cutoff = LocalDate.now().minusDays(10) + private val dueDate = LocalDate.now().plusMonths(1) + private val debitDate = LocalDate.now().plusMonths(1).plusDays(14) + + private fun period(): ContributionPeriod = contributionFactory.createPeriod( + startDate = periodStart, + endDate = periodEnd, + halfYearCutoffDate = cutoff, + ) + + private fun member( + incasso: Boolean, + memberType: MemberType = MemberType.REGULAR, + startDate: LocalDate = LocalDate.now().minusMonths(1), + endDate: LocalDate? = null, + email: String? = null, + ): User { + val user = userFactory.createUserWithRole(Role.MEMBER) + if (email != null) { + user.email = email + persist(user) + } + userFactory.createMembership( + user, + memberType = memberType, + startDate = startDate, + endDate = endDate, + incasso = incasso, + ) + return user + } + + // ── Requests ───────────────────────────────────────────────────────────── + + private fun preview(board: User, periodId: Long?, vararg userIds: Long?) = mvc.perform( + post("/contributions/bulk/email/preview") + .with(bearer(board)) + .contentType(MediaType.APPLICATION_JSON) + .content( + jsonMapper.writeValueAsString( + mapOf("contributionPeriodId" to periodId, "userIds" to userIds.toList()), + ), + ), + ) + + private fun sendBody( + periodId: Long?, + userIds: List, + forciblyIncluded: List = emptyList(), + kindOverrides: Map = emptyMap(), + feeTypeOverrides: Map = emptyMap(), + paymentDueDate: LocalDate? = dueDate, + debitDate: LocalDate? = this.debitDate, + ): String = jsonMapper.writeValueAsString( + mapOf( + "contributionPeriodId" to periodId, + "userIds" to userIds, + "forciblyIncludedUserIds" to forciblyIncluded, + "kindOverrides" to kindOverrides.mapKeys { it.key.toString() }, + "feeTypeOverrides" to feeTypeOverrides.mapKeys { it.key.toString() }, + "paymentDueDate" to paymentDueDate?.toString(), + "debitDate" to debitDate?.toString(), + ), + ) + + private fun send(board: User, body: String) = mvc.perform( + post("/contributions/bulk/email/send") + .with(bearer(board)) + .contentType(MediaType.APPLICATION_JSON) + .content(body), + ) + + private fun readEmail( + board: User, + kind: ContributionEmailKind, + periodId: Long?, + userId: Long?, + feeType: BulkFeeType? = null, + ) = mvc.perform( + get("/contributions/bulk/email/message") + .with(bearer(board)) + .param("kind", kind.name) + .param("contributionPeriodId", "$periodId") + .param("userId", "$userId") + .param("date", dueDate.toString()) + .apply { if (feeType != null) param("feeType", feeType.name) }, + ) + + // ── The table ──────────────────────────────────────────────────────────── + + @Nested + inner class WhatTheTableSays { + + @Test + fun `each selected member is routed by their direct-debit flag`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val onDirectDebit = member(incasso = true) + val onTransfer = member(incasso = false) + + preview(board, period.id, onDirectDebit.id, onTransfer.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.contributionPeriodId").value(period.id)) + .andExpect(jsonPath("$.rows.length()").value(2)) + .andExpect( + jsonPath("$.rows[?(@.userId == ${onDirectDebit.id})].defaultKind") + .value("INCASSO_NOTIFICATION"), + ) + .andExpect(jsonPath("$.rows[?(@.userId == ${onTransfer.id})].defaultKind").value("REMINDER")) + } + + @Test + fun `the table is exactly the selection`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val selected = member(incasso = false) + member(incasso = false) + + preview(board, period.id, selected.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows.length()").value(1)) + .andExpect(jsonPath("$.rows[0].userId").value(selected.id)) + } + + @Test + fun `an id naming nobody is named rather than left out of the count`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val selected = member(incasso = false) + + preview(board, period.id, selected.id, 9_999_999L) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows.length()").value(1)) + .andExpect(jsonPath("$.unknownUserIds.length()").value(1)) + .andExpect(jsonPath("$.unknownUserIds[0]").value(9_999_999L)) + } + + @Test + fun `an honorary member is hard-excluded, owing nothing`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + + preview(board, period.id, honorary.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows[0].disposition").value("EXCLUDED")) + .andExpect(jsonPath("$.rows[0].reason").value("HONORARY")) + .andExpect(jsonPath("$.rows[0].feeType").doesNotExist()) + } + + @Test + fun `a member with no address on file is hard-excluded`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val unreachable = member(incasso = false, email = "") + + preview(board, period.id, unreachable.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows[0].disposition").value("EXCLUDED")) + .andExpect(jsonPath("$.rows[0].reason").value("NO_EMAIL")) + } + + @Test + fun `a member who has paid is warned about rather than dropped`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val paid = member(incasso = false) + persist(Contribution(id = Contribution.Id(paid.id, period.id), user = paid, contributionPeriod = period)) + + preview(board, period.id, paid.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows[0].disposition").value("WARNING")) + .andExpect(jsonPath("$.rows[0].reason").value("ALREADY_PAID")) + } + + @Test + fun `a member who held no membership during the period is warned about`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val former = member( + incasso = false, + startDate = periodStart.minusYears(3), + endDate = periodStart.minusYears(2), + ) + + preview(board, period.id, former.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows[0].disposition").value("WARNING")) + .andExpect(jsonPath("$.rows[0].reason").value("NOT_MEMBER_IN_PERIOD")) + } + + @Test + fun `each row is priced from the period, by the cutoff the period carries`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val onTheCutoff = member(incasso = false, startDate = cutoff) + val afterIt = member(incasso = false, startDate = cutoff.plusDays(1)) + + preview(board, period.id, onTheCutoff.id, afterIt.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows[?(@.userId == ${onTheCutoff.id})].feeType").value("FULL_YEAR_FEE")) + .andExpect(jsonPath("$.rows[?(@.userId == ${onTheCutoff.id})].amount").value(45.0)) + .andExpect(jsonPath("$.rows[?(@.userId == ${afterIt.id})].feeType").value("HALF_YEAR_FEE")) + .andExpect(jsonPath("$.rows[?(@.userId == ${afterIt.id})].amount").value(25.0)) + } + + @Test + fun `a row carries when each statement was last sent, separately`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val chased = member(incasso = true) + persist( + ContributionReminder( + user = chased, + contributionPeriod = period, + feeType = BulkFeeType.FULL_YEAR_FEE, + amount = 45.0, + paymentDueDate = dueDate, + askedAt = Instant.now().minusSeconds(86_400), + ), + ) + + preview(board, period.id, chased.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.rows[0].lastRemindedOn").isNotEmpty) + .andExpect(jsonPath("$.rows[0].lastNotifiedOn").doesNotExist()) + } + } + + // ── The send ───────────────────────────────────────────────────────────── + + @Nested + inner class WhatTheSendDoes { + + @Test + fun `one confirmation writes both statements and reports each`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val onDirectDebit = member(incasso = true) + val onTransfer = member(incasso = false) + + send(board, sendBody(period.id, listOf(onDirectDebit.id, onTransfer.id))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(1)) + .andExpect(jsonPath("$.incassoNotificationsSent").value(1)) + .andExpect(jsonPath("$.notWrittenTo").value(0)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!).map { it.userId }) + .containsExactly(onTransfer.id) + assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!).map { it.userId }) + .containsExactly(onDirectDebit.id) + } + + @Test + fun `a switched member gets the statement the treasurer chose`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val onDirectDebit = member(incasso = true) + + send( + board, + sendBody( + period.id, + listOf(onDirectDebit.id), + kindOverrides = mapOf(onDirectDebit.id to ContributionEmailKind.REMINDER), + ), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(1)) + .andExpect(jsonPath("$.incassoNotificationsSent").value(0)) + + assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `a warned member is written to only once ticked back in`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val paid = member(incasso = false) + persist(Contribution(id = Contribution.Id(paid.id, period.id), user = paid, contributionPeriod = period)) + + send(board, sendBody(period.id, listOf(paid.id))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(0)) + .andExpect(jsonPath("$.notWrittenTo").value(1)) + + send(board, sendBody(period.id, listOf(paid.id), forciblyIncluded = listOf(paid.id))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(1)) + } + + @Test + fun `a hard-excluded member is not written to`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + + send(board, sendBody(period.id, listOf(honorary.id))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(0)) + .andExpect(jsonPath("$.notWrittenTo").value(1)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `sending twice records two asks rather than overwriting the first`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val chased = member(incasso = false) + + send(board, sendBody(period.id, listOf(chased.id))).andExpect(status().isOk) + send(board, sendBody(period.id, listOf(chased.id))).andExpect(status().isOk) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).hasSize(2) + } + + @Test + fun `an overridden fee type is what the record states`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send( + board, + sendBody(period.id, listOf(member.id), feeTypeOverrides = mapOf(member.id to BulkFeeType.ALUMNI_FEE)), + ).andExpect(status().isOk) + + val written = reminderRepository.findByContributionPeriod_Id(period.id!!).single() + assertThat(written.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) + assertThat(written.amount).isEqualTo(10.0) + } + } + + // ── Refusals ───────────────────────────────────────────────────────────── + + /** + * Every refusal names the request field it is about and a stable code. Those field + * names are what the wizard routes a refusal on, so they are asserted here rather than + * left to the client to discover. + */ + @Nested + inner class WhenASendIsRefused { + + @Test + fun `a fee type naming somebody the send skips refuses the whole thing`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + + send( + board, + sendBody( + period.id, + listOf(member.id, honorary.id), + feeTypeOverrides = mapOf(honorary.id to BulkFeeType.ALUMNI_FEE), + ), + ) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.errors[0].field").value("feeTypeOverrides")) + .andExpect(jsonPath("$.errors[0].code").value("NonRecipientFeeTypeUserIds")) + .andExpect(jsonPath("$.errors[0].values[0]").value(honorary.id)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `an email chosen for somebody the send skips refuses the whole thing`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + + send( + board, + sendBody( + period.id, + listOf(member.id, honorary.id), + kindOverrides = mapOf(honorary.id to ContributionEmailKind.REMINDER), + ), + ) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.errors[0].field").value("kindOverrides")) + .andExpect(jsonPath("$.errors[0].code").value("NonRecipientEmailKindUserIds")) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `an id naming nobody is refused rather than dropped`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send(board, sendBody(period.id, listOf(member.id, GONE))) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.errors[0].field").value("userIds")) + .andExpect(jsonPath("$.errors[0].code").value("UnknownUserIds")) + .andExpect(jsonPath("$.errors[0].values[0]").value(GONE)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `naming the same member twice is refused`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send(board, sendBody(period.id, listOf(member.id, member.id))) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.errors[0].field").value("userIds")) + .andExpect(jsonPath("$.errors[0].code").value("DuplicateUserIds")) + .andExpect(jsonPath("$.errors[0].values[0]").value(member.id)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `ticking back in somebody the send still will not write to is refused`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + + send(board, sendBody(period.id, listOf(honorary.id), forciblyIncluded = listOf(honorary.id))) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.errors[0].field").value("forciblyIncludedUserIds")) + .andExpect(jsonPath("$.errors[0].code").value("NonRecipientForcedUserIds")) + .andExpect(jsonPath("$.errors[0].values[0]").value(honorary.id)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `ticking back in somebody who is not in the selection is refused`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send(board, sendBody(period.id, listOf(member.id), forciblyIncluded = listOf(GONE))) + .andExpect(status().isConflict) + .andExpect(jsonPath("$.errors[0].field").value("forciblyIncludedUserIds")) + .andExpect(jsonPath("$.errors[0].code").value("UnknownForcedUserIds")) + .andExpect(jsonPath("$.errors[0].values[0]").value(GONE)) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `a payment request is refused without the date it promises`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send(board, sendBody(period.id, listOf(member.id), paymentDueDate = null)) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("paymentDueDate")) + .andExpect(jsonPath("$.errors[0].code").value("DateRequired")) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `a date that has already passed is refused against its own field`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send(board, sendBody(period.id, listOf(member.id), paymentDueDate = LocalDate.now().minusDays(1))) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("paymentDueDate")) + .andExpect(jsonPath("$.errors[0].code").value("Future")) + + send(board, sendBody(period.id, listOf(member.id), debitDate = LocalDate.now())) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("debitDate")) + .andExpect(jsonPath("$.errors[0].code").value("Future")) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `a date before the period starts is refused against its own field`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val start = LocalDate.now().plusMonths(2) + val period = contributionFactory.createPeriod( + startDate = start, + endDate = start.plusYears(1), + halfYearCutoffDate = start.plusMonths(6), + ) + val member = member(incasso = false, startDate = start) + + send( + board, + sendBody( + period.id, + listOf(member.id), + paymentDueDate = LocalDate.now().plusDays(1), + debitDate = null, + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("paymentDueDate")) + .andExpect(jsonPath("$.errors[0].code").value("DateOutsideContributionPeriod")) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + + @Test + fun `a date far beyond the period is refused, one shortly after it is not`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send( + board, + sendBody(period.id, listOf(member.id), paymentDueDate = periodEnd.plusMonths(3).plusDays(1)), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("paymentDueDate")) + .andExpect(jsonPath("$.errors[0].code").value("DateOutsideContributionPeriod")) + + send(board, sendBody(period.id, listOf(member.id), paymentDueDate = periodEnd.plusMonths(3))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(1)) + } + + @Test + fun `a selection larger than the cap is refused against the field that carries it`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + val overCap: List = (1L..1001L).toList() + + send(board, sendBody(period.id, listOf(member.id), forciblyIncluded = overCap)) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("forciblyIncludedUserIds")) + .andExpect(jsonPath("$.errors[0].code").value("Size")) + + send( + board, + sendBody( + period.id, + listOf(member.id), + kindOverrides = overCap.associateWith { ContributionEmailKind.REMINDER }, + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("kindOverrides")) + .andExpect(jsonPath("$.errors[0].code").value("Size")) + + send( + board, + sendBody( + period.id, + listOf(member.id), + feeTypeOverrides = overCap.associateWith { BulkFeeType.FULL_YEAR_FEE }, + ), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.errors[0].field").value("feeTypeOverrides")) + .andExpect(jsonPath("$.errors[0].code").value("Size")) + } + + @Test + fun `a date nobody in the batch needs may be left out`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + send(board, sendBody(period.id, listOf(member.id), debitDate = null)) + .andExpect(status().isOk) + .andExpect(jsonPath("$.remindersSent").value(1)) + } + + @Test + fun `an empty selection is refused`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + + send(board, sendBody(period.id, emptyList())).andExpect(status().isBadRequest) + } + } + + // ── Reading one email ──────────────────────────────────────────────────── + + @Nested + inner class ReadingOneEmail { + + @Test + fun `a payment request quotes the amount and where to send it`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + readEmail(board, ContributionEmailKind.REMINDER, period.id, member.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.kind").value("REMINDER")) + .andExpect(jsonPath("$.feeType").value("FULL_YEAR_FEE")) + .andExpect(jsonPath("$.recipientEmail").value(member.email)) + } + + @Test + fun `the kind asked for is the kind rendered, whatever the member's flag says`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val onDirectDebit = member(incasso = true) + + readEmail(board, ContributionEmailKind.REMINDER, period.id, onDirectDebit.id) + .andExpect(status().isOk) + .andExpect(jsonPath("$.kind").value("REMINDER")) + } + + @Test + fun `a hard-excluded member has no email to read`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + + readEmail(board, ContributionEmailKind.REMINDER, period.id, honorary.id) + .andExpect(status().isNotFound) + } + + @Test + fun `reading writes nothing`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val member = member(incasso = false) + + readEmail(board, ContributionEmailKind.REMINDER, period.id, member.id).andExpect(status().isOk) + + assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() + } + } + + // ── Who may do this ────────────────────────────────────────────────────── + + @Nested + inner class Authorisation { + + @Test + fun `a member may not read the table`() { + val member = userFactory.createUserWithRole(Role.MEMBER) + val period = period() + + preview(member, period.id, member.id).andExpect(status().isForbidden) + } + + @Test + fun `a member may not send`() { + val member = userFactory.createUserWithRole(Role.MEMBER) + val period = period() + + send(member, sendBody(period.id, listOf(member.id))).andExpect(status().isForbidden) + } + + @Test + fun `a member may not read somebody's email`() { + val member = userFactory.createUserWithRole(Role.MEMBER) + val period = period() + + readEmail(member, ContributionEmailKind.REMINDER, period.id, member.id) + .andExpect(status().isForbidden) + } + } + + /** The table and the send read one plan, so this asserts they name the same members. */ + @Test + fun `the send writes to exactly the members the table said it would`() { + val board = userFactory.createUserWithRole(Role.BOARD) + val period = period() + val onDirectDebit = member(incasso = true) + val onTransfer = member(incasso = false) + val honorary = member(incasso = false, memberType = MemberType.HONORARY) + val selection = listOf(onDirectDebit.id, onTransfer.id, honorary.id) + + val table = preview(board, period.id, *selection.toTypedArray()) + .andExpect(status().isOk) + .andReturn().response.contentAsString + + send(board, sendBody(period.id, selection)).andExpect(status().isOk) + + val includedIds = jsonMapper.readTree(table)["rows"] + .filter { it["disposition"].asString() == "INCLUDED" } + .map { it["userId"].asLong() } + val writtenTo = reminderRepository.findByContributionPeriod_Id(period.id!!).map { it.userId } + + preNotificationRepository.findByContributionPeriod_Id(period.id!!).map { it.userId } + + assertThat(writtenTo).containsExactlyInAnyOrderElementsOf(includedIds) + } +} diff --git a/services/api/src/integrationTest/kotlin/net/blueshell/api/contribution/web/FeeCycleControllerIT.kt b/services/api/src/integrationTest/kotlin/net/blueshell/api/contribution/web/FeeCycleControllerIT.kt deleted file mode 100644 index 5df79beb3..000000000 --- a/services/api/src/integrationTest/kotlin/net/blueshell/api/contribution/web/FeeCycleControllerIT.kt +++ /dev/null @@ -1,608 +0,0 @@ -package net.blueshell.api.contribution.web - -import net.blueshell.api.contribution.domain.ContributionReminderEmailJob -import net.blueshell.api.contribution.domain.IncassoNotificationEmailJob -import net.blueshell.api.contribution.persistence.Contribution -import net.blueshell.api.contribution.persistence.ContributionPeriod -import net.blueshell.api.contribution.persistence.ContributionReminder -import net.blueshell.api.contribution.persistence.ContributionReminderRepository -import net.blueshell.api.contribution.persistence.IncassoNotificationRepository -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.enums.MemberType -import net.blueshell.api.shared.enums.Role -import net.blueshell.api.shared.job.EmailJobs -import net.blueshell.api.testsupport.UserTestSupport -import net.blueshell.api.user.persistence.User -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -import tools.jackson.databind.ObjectMapper -import java.time.LocalDate -import java.time.ZoneOffset - -/** - * The fee cycle end to end: what the preview says, what the send does, and that the two - * agree. - */ -@SpringBootTest -class FeeCycleControllerIT : UserTestSupport() { - - @Autowired - private lateinit var reminderRepository: ContributionReminderRepository - - @Autowired - private lateinit var preNotificationRepository: IncassoNotificationRepository - - @Autowired - private lateinit var reminderEmailJob: ContributionReminderEmailJob - - @Autowired - private lateinit var preNotificationEmailJob: IncassoNotificationEmailJob - - @Autowired - private lateinit var jsonMapper: ObjectMapper - - private val periodStart = LocalDate.now().minusMonths(2) - private val periodEnd = LocalDate.now().plusMonths(6) - - /** The label the emails carry, derived here so the assertions do not pin a calendar year. */ - private val academicYear = - if (periodEnd.year > periodStart.year) "${periodStart.year}/${periodEnd.year}" else "${periodStart.year}" - - private val cutoff = LocalDate.now().minusDays(10) - private val dueDate = LocalDate.now().plusMonths(1) - private val debitDate = LocalDate.now().plusMonths(1).plusDays(14) - - private fun period(): ContributionPeriod = contributionFactory.createPeriod( - startDate = periodStart, - endDate = periodEnd, - halfYearCutoffDate = cutoff, - ) - - /** A member of the period, on the side of the partition their flag puts them on. */ - private fun member( - incasso: Boolean, - memberType: MemberType = MemberType.REGULAR, - startDate: LocalDate = LocalDate.now().minusMonths(1), - email: String? = null, - ): User { - val user = userFactory.createUserWithRole(Role.MEMBER) - if (email != null) { - user.email = email - persist(user) - } - userFactory.createMembership(user, memberType = memberType, startDate = startDate, incasso = incasso) - return user - } - - private fun sendBody( - periodId: Long?, - feeTypeOverrides: Map = emptyMap(), - ): String = jsonMapper.writeValueAsString( - mapOf( - "contributionPeriodId" to periodId, - "paymentDueDate" to dueDate.toString(), - "debitDate" to debitDate.toString(), - "feeTypeOverrides" to feeTypeOverrides.mapKeys { it.key.toString() }, - ), - ) - - private fun previewEmail(board: User, periodId: Long?, userId: Long?, feeType: BulkFeeType? = null) = - mvc.perform( - get("/contributions/fee-cycle/email-preview") - .with(bearer(board)) - .param("contributionPeriodId", "$periodId") - .param("userId", "$userId") - .param("paymentDueDate", dueDate.toString()) - .param("debitDate", debitDate.toString()) - .apply { if (feeType != null) param("feeType", feeType.name) }, - ) - - private fun preview(board: User, periodId: Long?) = - mvc.perform(get("/contributions/fee-cycle").with(bearer(board)).param("contributionPeriodId", "$periodId")) - - private fun send(board: User, body: String) = - mvc.perform( - post("/contributions/fee-cycle/send") - .with(bearer(board)) - .contentType(MediaType.APPLICATION_JSON) - .content(body), - ) - - @Nested - inner class ThePreview { - - @Test - fun `lists every unpaid member of the period, split by the direct-debit flag`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onDirectDebit = member(incasso = true) - val onTransfer = member(incasso = false) - - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.contributionPeriodId").value(period.id)) - .andExpect(jsonPath("$.rows.length()").value(2)) - .andExpect(jsonPath("$.rows[?(@.userId == ${onDirectDebit.id})].group").value("DIRECT_DEBIT")) - .andExpect(jsonPath("$.rows[?(@.userId == ${onTransfer.id})].group").value("TRANSFER")) - } - - @Test - fun `leaves out a member who has already paid`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val paid = member(incasso = false) - val unpaid = member(incasso = false) - persist(Contribution(id = Contribution.Id(paid.id, period.id), user = paid, contributionPeriod = period)) - - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.rows.length()").value(1)) - .andExpect(jsonPath("$.rows[0].userId").value(unpaid.id)) - } - - @Test - fun `shows an honorary member excluded, owing nothing`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val honorary = member(incasso = false, memberType = MemberType.HONORARY) - - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.rows[?(@.userId == ${honorary.id})].disposition").value("EXCLUDED")) - .andExpect(jsonPath("$.rows[?(@.userId == ${honorary.id})].reason").value("HONORARY")) - .andExpect(jsonPath("$.rows[?(@.userId == ${honorary.id})].feeType").isEmpty) - } - - @Test - fun `shows a member with no email address excluded`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val unreachable = member(incasso = false, email = "") - - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.rows[?(@.userId == ${unreachable.id})].disposition").value("EXCLUDED")) - .andExpect(jsonPath("$.rows[?(@.userId == ${unreachable.id})].reason").value("NO_EMAIL")) - } - - @Test - fun `prices each row from the period, by the cutoff the period carries`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val startedOnTheCutoff = member(incasso = false, startDate = cutoff) - val startedAfterIt = member(incasso = false, startDate = cutoff.plusDays(1)) - - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.rows[?(@.userId == ${startedOnTheCutoff.id})].feeType").value("FULL_YEAR_FEE")) - .andExpect(jsonPath("$.rows[?(@.userId == ${startedOnTheCutoff.id})].amount").value(45.0)) - .andExpect(jsonPath("$.rows[?(@.userId == ${startedAfterIt.id})].feeType").value("HALF_YEAR_FEE")) - .andExpect(jsonPath("$.rows[?(@.userId == ${startedAfterIt.id})].amount").value(25.0)) - } - - /** - * The flag on a membership that has ended is not how the member pays now, and - * sending the wrong statement on a stale flag costs them money. - */ - @Test - fun `partitions by the active membership, not one that has ended`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val switched = userFactory.createUserWithRole(Role.MEMBER) - userFactory.createMembership( - switched, - startDate = LocalDate.now().minusMonths(2), - endDate = LocalDate.now().minusDays(20), - incasso = true, - ) - userFactory.createMembership(switched, startDate = LocalDate.now().minusDays(19), incasso = false) - - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.rows[?(@.userId == ${switched.id})].group").value("TRANSFER")) - } - - @Test - fun `is refused to a member`() { - val member = userFactory.createUserWithRole(Role.MEMBER) - preview(member, period().id).andExpect(status().isForbidden) - } - } - - @Nested - inner class TheSend { - - @Test - fun `writes to both sides from one confirmation and reports each separately`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onDirectDebit = member(incasso = true) - val onTransfer = member(incasso = false) - - send(board, sendBody(period.id)) - .andExpect(status().isOk) - .andExpect(jsonPath("$.paymentRequestsQueued").value(1)) - .andExpect(jsonPath("$.preNotificationsQueued").value(1)) - .andExpect(jsonPath("$.excluded").value(0)) - - assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!).map { it.userId }) - .containsExactly(onTransfer.id) - assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!).map { it.userId }) - .containsExactly(onDirectDebit.id) - } - - @Test - fun `queues one email per member rather than one request per member`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = false) - member(incasso = false) - member(incasso = true) - - send(board, sendBody(period.id)).andExpect(status().isOk) - - assertThat(findJobsByType(EmailJobs.ContributionReminder.type)).hasSize(2) - assertThat(findJobsByType(EmailJobs.IncassoNotification.type)).hasSize(1) - } - - @Test - fun `writes to nobody the preview excluded`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = false, memberType = MemberType.HONORARY) - member(incasso = true, email = "") - - send(board, sendBody(period.id)) - .andExpect(status().isOk) - .andExpect(jsonPath("$.paymentRequestsQueued").value(0)) - .andExpect(jsonPath("$.preNotificationsQueued").value(0)) - .andExpect(jsonPath("$.excluded").value(2)) - } - - /** - * The acceptance criterion behind sharing one plan: the send writes to exactly the - * members the preview said it would, on the side the preview put them on. - */ - @Test - fun `writes to exactly the members the preview said it would`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = true) - member(incasso = true, memberType = MemberType.ALUMNI) - member(incasso = false) - member(incasso = false, memberType = MemberType.HONORARY) - member(incasso = false, email = "") - - val previewed = jsonMapper.readTree( - preview(board, period.id).andExpect(status().isOk).andReturn().response.contentAsString, - )["rows"] - val expectedTransfer = previewed - .filter { it["group"].asString() == "TRANSFER" && it["disposition"].asString() == "INCLUDED" } - .map { it["userId"].asLong() } - val expectedDirectDebit = previewed - .filter { it["group"].asString() == "DIRECT_DEBIT" && it["disposition"].asString() == "INCLUDED" } - .map { it["userId"].asLong() } - - send(board, sendBody(period.id)) - .andExpect(status().isOk) - .andExpect(jsonPath("$.paymentRequestsQueued").value(expectedTransfer.size)) - .andExpect(jsonPath("$.preNotificationsQueued").value(expectedDirectDebit.size)) - - assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!).map { it.userId }) - .containsExactlyInAnyOrderElementsOf(expectedTransfer) - assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!).map { it.userId }) - .containsExactlyInAnyOrderElementsOf(expectedDirectDebit) - } - - /** - * The period's fees are editable, so a record that only stored the type would let a - * change of next year's fee rewrite what last year's email is recorded as saying. - */ - @Test - fun `records the amount it stated, not only the type`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = false, startDate = cutoff.plusDays(1)) - member(incasso = true, startDate = cutoff) - - send(board, sendBody(period.id)).andExpect(status().isOk) - - assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!).single().amount) - .isEqualTo(25.0) - assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!).single().amount) - .isEqualTo(45.0) - } - - @Test - fun `records what each statement said, so the email quotes the true reason`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false, startDate = cutoff.plusDays(1)) - val onDirectDebit = member(incasso = true, memberType = MemberType.ALUMNI) - - send(board, sendBody(period.id)).andExpect(status().isOk) - - val reminder = reminderRepository.findByContributionPeriod_Id(period.id!!).single() - assertThat(reminder.userId).isEqualTo(onTransfer.id) - assertThat(reminder.feeType).isEqualTo(BulkFeeType.HALF_YEAR_FEE) - assertThat(reminder.paymentDueDate).isEqualTo(dueDate) - - val preNotification = preNotificationRepository.findByContributionPeriod_Id(period.id!!).single() - assertThat(preNotification.userId).isEqualTo(onDirectDebit.id) - assertThat(preNotification.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) - assertThat(preNotification.debitDate).isEqualTo(debitDate) - } - - /** - * The treasurer chases, and may run the cycle over a period as often as they need. - * Each run is its own ask, so a member asked three times reads as three asks rather - * than one row that only remembers the last. - */ - @Test - fun `asking again writes another ask, as often as it is done`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false) - - send(board, sendBody(period.id)).andExpect(status().isOk) - send(board, sendBody(period.id, mapOf(onTransfer.id to BulkFeeType.ALUMNI_FEE))) - .andExpect(status().isOk) - send(board, sendBody(period.id)).andExpect(status().isOk) - - val asks = reminderRepository.findByContributionPeriod_Id(period.id!!) - assertThat(asks).hasSize(3) - assertThat(asks.map { it.userId }.distinct()).containsExactly(onTransfer.id) - assertThat(asks.map { it.feeType }) - .containsExactly(BulkFeeType.FULL_YEAR_FEE, BulkFeeType.ALUMNI_FEE, BulkFeeType.FULL_YEAR_FEE) - } - - @Test - fun `notifying again writes another pre-notification, so a moved debit date can be re-told`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = true) - - send(board, sendBody(period.id)).andExpect(status().isOk) - send(board, sendBody(period.id)).andExpect(status().isOk) - - assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!)).hasSize(2) - } - - @Test - fun `each ask queues its own email`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = false) - - send(board, sendBody(period.id)).andExpect(status().isOk) - send(board, sendBody(period.id)).andExpect(status().isOk) - - assertThat(findJobsByType(EmailJobs.ContributionReminder.type)).hasSize(2) - } - - @Test - fun `shows the most recent of a member's asks`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false) - persist( - ContributionReminder( - user = onTransfer, - contributionPeriod = period, - askedAt = LocalDate.now().minusMonths(3).atStartOfDay().toInstant(ZoneOffset.UTC), - ), - ) - - send(board, sendBody(period.id)).andExpect(status().isOk) - - assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).hasSize(2) - preview(board, period.id) - .andExpect(status().isOk) - .andExpect( - jsonPath("$.rows[?(@.userId == ${onTransfer.id})].lastAskedOn") - .value(LocalDate.now().toString()), - ) - } - - @Test - fun `shows when a member was last asked, on their side of the partition`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false) - val onDirectDebit = member(incasso = true) - - send(board, sendBody(period.id)).andExpect(status().isOk) - - val today = LocalDate.now().toString() - preview(board, period.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.rows[?(@.userId == ${onTransfer.id})].lastAskedOn").value(today)) - .andExpect(jsonPath("$.rows[?(@.userId == ${onDirectDebit.id})].lastAskedOn").value(today)) - } - - @Test - fun `refuses a fee type naming a member the cycle does not write to`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - member(incasso = false) - val honorary = member(incasso = false, memberType = MemberType.HONORARY) - - send(board, sendBody(period.id, mapOf(honorary.id to BulkFeeType.ALUMNI_FEE))) - .andExpect(status().isConflict) - .andExpect(jsonPath("$.errors[0].code").value("NonRecipientFeeTypeUserIds")) - .andExpect(jsonPath("$.errors[0].values[0]").value(honorary.id)) - - assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() - } - - @Test - fun `is refused to a member`() { - val member = userFactory.createUserWithRole(Role.MEMBER) - send(member, sendBody(period().id)).andExpect(status().isForbidden) - } - } - - @Nested - inner class TheEmailsThatGoOut { - - @Test - fun `the payment request states the amount, the reason and the due date`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false, startDate = cutoff.plusDays(1)) - - send(board, sendBody(period.id)).andExpect(status().isOk) - emailTransportClient.reset() - val ask = reminderRepository.findByContributionPeriod_Id(period.id!!).single() - reminderEmailJob.handle( - jsonMapper.writeValueAsString(EmailJobs.ContributionReminderPayload(ask.id!!)), - ) - - val sent = emailTransportClient.sentEmails.single() - assertThat(sent.toEmail).isEqualTo(onTransfer.email) - assertThat(sent.htmlContent) - .contains("25,00") - .contains("the half-year fee") - .contains(dueDate.dayOfMonth.toString()) - } - - @Test - fun `the pre-notification states the amount, the reason and the debit date`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onDirectDebit = member(incasso = true, startDate = cutoff) - - send(board, sendBody(period.id)).andExpect(status().isOk) - emailTransportClient.reset() - val notification = preNotificationRepository.findByContributionPeriod_Id(period.id!!).single() - preNotificationEmailJob.handle( - jsonMapper.writeValueAsString(EmailJobs.IncassoNotificationPayload(notification.id!!)), - ) - - val sent = emailTransportClient.sentEmails.single() - assertThat(sent.toEmail).isEqualTo(onDirectDebit.email) - assertThat(sent.htmlContent) - .contains("45,00") - .contains("the full-year fee") - .contains(debitDate.dayOfMonth.toString()) - .doesNotContain("Bank transfer") - } - } - - /** - * Reading one member's email before sending to a hundred. It renders through the shared - * preview renderer from the same `EmailContent` the send builds, which is what stops the - * two drifting. - */ - @Nested - inner class ReadingOneMembersEmail { - - @Test - fun `a transfer member reads the payment request`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false, startDate = cutoff.plusDays(1)) - - previewEmail(board, period.id, onTransfer.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.group").value("TRANSFER")) - .andExpect(jsonPath("$.feeType").value("HALF_YEAR_FEE")) - .andExpect(jsonPath("$.recipientEmail").value(onTransfer.email)) - .andExpect(jsonPath("$.subject").value("Please pay your Blueshell contribution ($academicYear)")) - } - - @Test - fun `a direct-debit member reads the pre-notification`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onDirectDebit = member(incasso = true, memberType = MemberType.ALUMNI) - - previewEmail(board, period.id, onDirectDebit.id) - .andExpect(status().isOk) - .andExpect(jsonPath("$.group").value("DIRECT_DEBIT")) - .andExpect(jsonPath("$.feeType").value("ALUMNI_FEE")) - .andExpect( - jsonPath("$.subject") - .value("Your Blueshell contribution will be collected automatically ($academicYear)"), - ) - } - - // The hosted images are inlined so the pictures show in a browser that cannot reach - // the asset host — which is what going through the shared renderer buys. - @Test - fun `the rendered body carries the amount, the reason and the images`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false, startDate = cutoff) - - val body = previewEmail(board, period.id, onTransfer.id) - .andExpect(status().isOk) - .andReturn().response.contentAsString - val html = jsonMapper.readTree(body)["html"].asString() - - assertThat(html) - .contains("45,00") - .contains("the full-year fee") - .contains("data:image/png;base64,") - } - - @Test - fun `an overridden fee type is what the email quotes`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false, startDate = cutoff) - - previewEmail(board, period.id, onTransfer.id, BulkFeeType.ALUMNI_FEE) - .andExpect(status().isOk) - .andExpect(jsonPath("$.feeType").value("ALUMNI_FEE")) - } - - @Test - fun `writes no record and queues no email`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val onTransfer = member(incasso = false) - - previewEmail(board, period.id, onTransfer.id).andExpect(status().isOk) - - assertThat(reminderRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() - assertThat(preNotificationRepository.findByContributionPeriod_Id(period.id!!)).isEmpty() - assertThat(findJobsByType(EmailJobs.ContributionReminder.type)).isEmpty() - assertThat(findJobsByType(EmailJobs.IncassoNotification.type)).isEmpty() - } - - @Test - fun `refuses a member the cycle sends nothing to`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val honorary = member(incasso = false, memberType = MemberType.HONORARY) - - previewEmail(board, period.id, honorary.id).andExpect(status().isNotFound) - } - - @Test - fun `refuses a member the cycle is not about`() { - val board = userFactory.createUserWithRole(Role.BOARD) - val period = period() - val stranger = userFactory.createUserWithRole(Role.MEMBER) - - previewEmail(board, period.id, stranger.id).andExpect(status().isNotFound) - } - - @Test - fun `is refused to a member`() { - val period = period() - val member = member(incasso = false) - - previewEmail(member, period.id, member.id).andExpect(status().isForbidden) - } - } -} \ No newline at end of file diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/BulkContributionEmailUseCases.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/BulkContributionEmailUseCases.kt new file mode 100644 index 000000000..84a9f2ccb --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/BulkContributionEmailUseCases.kt @@ -0,0 +1,271 @@ +package net.blueshell.api.contribution.domain + +import net.blueshell.api.contribution.api.ContributionPeriodService +import net.blueshell.api.contribution.persistence.ContributionPeriod +import net.blueshell.api.contribution.persistence.ContributionReminder +import net.blueshell.api.contribution.persistence.IncassoNotification +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.dto.bulk.BulkFieldRejected +import net.blueshell.api.shared.dto.bulk.BulkSelectionRejected +import net.blueshell.api.shared.dto.bulk.BulkUserSelection +import net.blueshell.api.user.api.UserService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.LocalDate + +/** + * Sending a period's payment emails to a selection. One confirmation, two statements: the + * direct-debit members are told what will be taken, the rest are asked to transfer. + * + * Each send writes its own records, so chasing a member twice leaves two asks. + * + * A refusal's `message` is fixed per code and interpolates nothing, per ADR-026: it is what a + * log or a direct caller reads. The browser composes its own from the code, in + * `COMPOSED_MESSAGES` in `services/frontend/src/utils/bulkRejection.ts`. + */ +@Service +class BulkContributionEmailUseCases( + private val planner: ContributionEmailPlanner, + private val periods: ContributionPeriodService, + private val users: UserService, + private val reminders: ContributionReminderService, + private val preNotifications: IncassoNotificationService, +) { + @Transactional(readOnly = true) + fun preview(contributionPeriodId: Long, userIds: Collection): ContributionEmailPlan = + planner.plan(contributionPeriodId, userIds) + + @Transactional + fun send( + contributionPeriodId: Long, + userIds: Collection, + forciblyIncluded: Set, + kindOverrides: Map, + paymentDueDate: LocalDate?, + debitDate: LocalDate?, + feeTypeOverrides: Map, + ): ContributionEmailResult { + val plan = planner.plan(contributionPeriodId, userIds) + rejectIncoherentSelection(userIds, forciblyIncluded, plan) + + val recipients = plan.recipients(forciblyIncluded) + rejectStatementsForNonRecipients(recipients, forciblyIncluded, feeTypeOverrides, kindOverrides) + + val kindOf = { row: ContributionEmailRow -> kindOverrides[row.userId] ?: row.defaultKind } + val period = periods.findById(contributionPeriodId) + rejectDates(period, recipients.map(kindOf), paymentDueDate, debitDate) + + val sentAt = Instant.now() + var remindersSent = 0 + var notificationsSent = 0 + + for (row in recipients) { + // Only an honorary member has no fee type, and honorary is a hard exclusion. + val feeType = feeTypeOverrides[row.userId] ?: row.feeType!! + val amount = resolveFeeAmount(feeType, period) + val member = users.findById(row.userId) + + when (kindOf(row)) { + ContributionEmailKind.REMINDER -> { + reminders.sendReminder( + reminders.create( + ContributionReminder( + user = member, + contributionPeriod = period, + feeType = feeType, + amount = amount, + paymentDueDate = requireNotNull(paymentDueDate), + askedAt = sentAt, + ), + ), + ) + remindersSent++ + } + + ContributionEmailKind.INCASSO_NOTIFICATION -> { + preNotifications.sendNotification( + preNotifications.create( + IncassoNotification( + user = member, + contributionPeriod = period, + feeType = feeType, + amount = amount, + debitDate = requireNotNull(debitDate), + askedAt = sentAt, + ), + ), + ) + notificationsSent++ + } + } + } + + return ContributionEmailResult( + remindersSent = remindersSent, + incassoNotificationsSent = notificationsSent, + notWrittenTo = userIds.distinct().size - recipients.size, + ) + } + + /** + * The selection has to describe itself before anything reads it: an id naming nobody, + * an id named twice, or a forced id that was never selected all mean the selection the + * caller holds and the one the api holds have parted company. + */ + private fun rejectIncoherentSelection( + userIds: Collection, + forciblyIncluded: Set, + plan: ContributionEmailPlan, + ) { + val violations = buildList { + userIds.groupBy { it }.filterValues { it.size > 1 }.keys.sorted().ifEmpty { null }?.let { repeated -> + add( + BulkSelectionRejected.Violation( + field = "userIds", + code = BulkSelectionRejected.DUPLICATE_USERS, + values = repeated, + message = "The selection names a user more than once.", + ), + ) + } + plan.unknownUserIds.ifEmpty { null }?.let { add(BulkUserSelection.unknownUsers(it)) } + strayIds(forciblyIncluded, userIds.toSet())?.let { stray -> + add( + BulkSelectionRejected.Violation( + field = "forciblyIncludedUserIds", + code = BulkSelectionRejected.UNKNOWN_FORCED, + values = stray, + message = "A forced id is not in the selection.", + ), + ) + } + } + if (violations.isEmpty()) return + throw BulkSelectionRejected(OBJECT_NAME, violations) + } + + /** + * A statement about somebody the send skips means the selection has moved on. Refused + * whole, rather than leaving the caller believing they changed something they did not. + */ + private fun rejectStatementsForNonRecipients( + recipients: List, + forciblyIncluded: Set, + feeTypeOverrides: Map, + kindOverrides: Map, + ) { + val ids = recipients.map { it.userId }.toSet() + val violations = buildList { + strayIds(forciblyIncluded, ids)?.let { stray -> + add( + BulkSelectionRejected.Violation( + field = "forciblyIncludedUserIds", + code = BulkSelectionRejected.NON_RECIPIENT_FORCED, + values = stray, + message = "A forced id names a user this send does not write to.", + ), + ) + } + strayIds(feeTypeOverrides.keys, ids)?.let { stray -> + add( + BulkSelectionRejected.Violation( + field = "feeTypeOverrides", + code = BulkSelectionRejected.NON_RECIPIENT_FEE_TYPES, + values = stray, + message = "${stray.size} of the fee types name members this send does not write to.", + ), + ) + } + strayIds(kindOverrides.keys, ids)?.let { stray -> + add( + BulkSelectionRejected.Violation( + field = "kindOverrides", + code = BulkSelectionRejected.NON_RECIPIENT_EMAIL_KINDS, + values = stray, + message = "${stray.size} of the chosen emails name members this send does not write to.", + ), + ) + } + } + if (violations.isEmpty()) return + throw BulkSelectionRejected(OBJECT_NAME, violations) + } + + /** + * Each date is required exactly when it reaches somebody, and each one the request + * states has to sit against the period being billed. + */ + private fun rejectDates( + period: ContributionPeriod, + kinds: List, + paymentDueDate: LocalDate?, + debitDate: LocalDate?, + ) { + val violations = + dateViolations("paymentDueDate", paymentDueDate, ContributionEmailKind.REMINDER in kinds, period) + + dateViolations( + "debitDate", + debitDate, + ContributionEmailKind.INCASSO_NOTIFICATION in kinds, + period, + ) + if (violations.isEmpty()) return + throw BulkFieldRejected(OBJECT_NAME, violations) + } + + private fun dateViolations( + field: String, + date: LocalDate?, + reachesSomebody: Boolean, + period: ContributionPeriod, + ): List = when { + date == null -> + if (reachesSomebody) { + listOf( + BulkFieldRejected.Violation( + field = field, + code = BulkFieldRejected.DATE_REQUIRED, + message = "The date is required, because an email in this batch states it.", + ), + ) + } else { + emptyList() + } + + date < period.startDate || date > period.endDate.plusMonths(MONTHS_PAST_PERIOD_END) -> listOf( + BulkFieldRejected.Violation( + field = field, + code = BulkFieldRejected.DATE_OUTSIDE_PERIOD, + message = "The date falls outside the contribution period.", + ), + ) + + else -> emptyList() + } + + private fun strayIds(stated: Set, recipients: Set): List? = + stated.filterNot { it in recipients }.sorted().ifEmpty { null } + + private companion object { + const val OBJECT_NAME = "SendPaymentEmailsRequest" + + /** + * How far past the end of a period a date may still fall. Chasing the last unpaid + * members in the final weeks needs a due date beyond the period; a mistyped year + * does not get one. + * + * Mirrored in the browser by `PERIOD_OVERHANG_MONTHS` in + * `services/frontend/src/utils/contributionEmail.ts`. Two implementations of one + * rule: changing this one means changing that one. + */ + const val MONTHS_PAST_PERIOD_END = 3L + } +} + +/** Counted per statement, because one total hides either half being zero. */ +data class ContributionEmailResult( + val remindersSent: Int, + val incassoNotificationsSent: Int, + val notWrittenTo: Int, +) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmail.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmail.kt new file mode 100644 index 000000000..1c0170ebd --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmail.kt @@ -0,0 +1,61 @@ +package net.blueshell.api.contribution.domain + +import io.swagger.v3.oas.annotations.media.Schema +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.dto.bulk.BulkRowDisposition +import net.blueshell.api.shared.dto.bulk.BulkRowReason +import net.blueshell.api.shared.enums.MemberType +import java.time.LocalDate + +/** Which of the two payment emails a member gets. */ +@Schema(name = "ContributionEmailKind", enumAsRef = true) +enum class ContributionEmailKind { + /** Asks the member to transfer what they owe, by a date. */ + REMINDER, + + /** Tells the member what will be taken, and when. Asks for nothing. */ + INCASSO_NOTIFICATION, +} + +/** One selected member: which email they get, what they owe, and whether they are written to. */ +data class ContributionEmailRow( + val userId: Long, + val name: String, + val memberType: MemberType, + val memberSince: LocalDate?, + val disposition: BulkRowDisposition, + val reason: BulkRowReason?, + /** From the member's direct-debit flag. A default the treasurer may overrule per row. */ + val defaultKind: ContributionEmailKind, + /** Null exactly when no fee applies, which is when the member is honorary. */ + val feeType: BulkFeeType?, + val amount: Double?, + // Both, so switching a row's email re-reads its history without a round trip. + val lastRemindedOn: LocalDate?, + val lastNotifiedOn: LocalDate?, +) { + val isHardExcluded: Boolean get() = disposition == BulkRowDisposition.EXCLUDED + + /** A warning is a default the caller can overrule; a hard exclusion is not. */ + fun willSend(forciblyIncluded: Set): Boolean = when (disposition) { + BulkRowDisposition.INCLUDED -> true + BulkRowDisposition.WARNING -> userId in forciblyIncluded + else -> false + } +} + +/** + * What one send would do to one selection. The preview and the send read the same plan, so + * they cannot disagree about who is written to or what they owe. + */ +data class ContributionEmailPlan( + val contributionPeriodId: Long, + val rows: List, + /** Selected ids resolving to nobody. No row can be drawn for them, and the send refuses them. */ + val unknownUserIds: List = emptyList(), +) { + fun byUserId(userId: Long): ContributionEmailRow? = rows.firstOrNull { it.userId == userId } + + fun recipients(forciblyIncluded: Set): List = + rows.filter { it.willSend(forciblyIncluded) } +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmailMessageService.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmailMessageService.kt new file mode 100644 index 000000000..b63ad5466 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmailMessageService.kt @@ -0,0 +1,75 @@ +package net.blueshell.api.contribution.domain + +import net.blueshell.api.contribution.api.ContributionPeriodService +import net.blueshell.api.email.api.EmailPreviewRenderer +import net.blueshell.api.platform.config.BankProperties +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.email.EmailContent +import net.blueshell.api.user.api.UserService +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate + +/** + * One member's payment email, rendered for reading. Built by the same builders the send + * uses, so the two cannot drift. Writes no record and queues no job. + */ +@Service +class ContributionEmailMessageService( + private val planner: ContributionEmailPlanner, + private val periods: ContributionPeriodService, + private val users: UserService, + private val renderer: EmailPreviewRenderer, + private val bank: BankProperties, +) { + /** [kind] is whatever the row is set to, so a switched row previews what it will get. */ + @Transactional(readOnly = true) + fun render( + kind: ContributionEmailKind, + contributionPeriodId: Long, + userId: Long, + date: LocalDate, + feeType: BulkFeeType?, + ): ContributionEmailMessage { + val row = planner.plan(contributionPeriodId, listOf(userId)).byUserId(userId) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "That member could not be read") + // A warned member may yet be overruled, so theirs is readable; a hard-excluded one + // has no email at all. + if (row.isHardExcluded) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "This send writes nothing to that member") + } + val effectiveFeeType = feeType ?: row.feeType!! + + val member = users.findById(userId) + val period = periods.findById(contributionPeriodId) + val amount = resolveFeeAmount(effectiveFeeType, period) + + val content: EmailContent = when (kind) { + ContributionEmailKind.REMINDER -> + createContributionReminderEmail(member, period, effectiveFeeType, amount, date, bank) + ContributionEmailKind.INCASSO_NOTIFICATION -> + createIncassoNotificationEmail(member, period, effectiveFeeType, amount, date) + } + + val rendered = renderer.render(content) + return ContributionEmailMessage( + kind = kind, + feeType = effectiveFeeType, + subject = rendered.subject, + html = rendered.html, + recipientEmail = content.recipientEmail, + recipientName = content.recipientName, + ) + } +} + +data class ContributionEmailMessage( + val kind: ContributionEmailKind, + val feeType: BulkFeeType, + val subject: String, + val html: String, + val recipientEmail: String, + val recipientName: String, +) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmailPlanner.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmailPlanner.kt new file mode 100644 index 000000000..5813abd06 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionEmailPlanner.kt @@ -0,0 +1,135 @@ +package net.blueshell.api.contribution.domain + +import net.blueshell.api.contribution.api.ContributionPeriodService +import net.blueshell.api.contribution.api.ContributionService +import net.blueshell.api.contribution.persistence.ContributionPeriod +import net.blueshell.api.shared.dto.bulk.BulkRowDisposition +import net.blueshell.api.shared.dto.bulk.BulkRowReason +import net.blueshell.api.shared.enums.MemberType +import net.blueshell.api.user.api.MembershipService +import net.blueshell.api.user.api.UserErasureService +import net.blueshell.api.user.api.UserService +import net.blueshell.api.user.persistence.Membership +import net.blueshell.api.user.persistence.User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * What the payment emails would do to a selection. Read by both the preview and the send, so + * they cannot disagree. Nothing here writes, enqueues or sends. + */ +@Service +class ContributionEmailPlanner( + private val periods: ContributionPeriodService, + private val contributions: ContributionService, + private val memberships: MembershipService, + private val users: UserService, + private val reminders: ContributionReminderService, + private val preNotifications: IncassoNotificationService, + private val erasure: UserErasureService, +) { + @Transactional(readOnly = true) + fun plan(contributionPeriodId: Long, userIds: Collection): ContributionEmailPlan { + val period = periods.findById(contributionPeriodId) + val selected = userIds.distinct() + + val held = memberships.findByUserIdsWithMembers(selected) + val paid = contributions.findByContributionPeriodId(contributionPeriodId).map { it.userId }.toSet() + val lastReminded = latestPerMember( + reminders.findByContributionPeriodId(contributionPeriodId).map { it.userId to it.askedAt }, + ) + val lastNotified = latestPerMember( + preNotifications.findByContributionPeriodId(contributionPeriodId).map { it.userId to it.askedAt }, + ) + + // The membership read already fetched the member; only somebody holding none costs a + // lookup. An id naming nobody is no row, so the plan names it instead of losing it. + val (known, unknown) = selected.partition { held.containsKey(it) || users.existsById(it) } + + val rows = known + .map { userId -> + val theirs = held[userId] ?: emptyList() + val member = theirs.firstOrNull()?.user ?: users.findById(userId) + row(userId, member, theirs, period, userId in paid, lastReminded[userId], lastNotified[userId]) + } + .sortedBy { it.name } + + return ContributionEmailPlan(contributionPeriodId, rows, unknown.sorted()) + } + + private fun row( + userId: Long, + member: User, + held: List, + period: ContributionPeriod, + alreadyPaid: Boolean, + lastRemindedOn: LocalDate?, + lastNotifiedOn: LocalDate?, + ): ContributionEmailRow { + val judged = judgedMembership(held) + val memberType = judged?.memberType ?: MemberType.NONE + val feeType = resolveFeeType(memberType, judged?.startDate, period) + val (disposition, reason) = decide(userId, member, held, period, alreadyPaid, feeType == null) + + return ContributionEmailRow( + userId = userId, + name = member.fullName, + memberType = memberType, + memberSince = judged?.startDate, + disposition = disposition, + reason = reason, + defaultKind = if (judged?.incasso == true) { + ContributionEmailKind.INCASSO_NOTIFICATION + } else { + ContributionEmailKind.REMINDER + }, + feeType = feeType, + amount = feeType?.let { resolveFeeAmount(it, period) }, + lastRemindedOn = lastRemindedOn, + lastNotifiedOn = lastNotifiedOn, + ) + } + + /** Hard exclusions first — nothing overrules them — then the two warnings. */ + private fun decide( + userId: Long, + member: User, + held: List, + period: ContributionPeriod, + alreadyPaid: Boolean, + owesNothing: Boolean, + ): Pair = when { + owesNothing -> BulkRowDisposition.EXCLUDED to BulkRowReason.HONORARY + + // Asked of the erasure snapshot: deletion anonymises the address to a placeholder that + // would pass an is-it-blank test, and leaves the memberships running. + erasure.isDeleted(userId) -> BulkRowDisposition.EXCLUDED to BulkRowReason.DELETED + + member.email.isBlank() -> BulkRowDisposition.EXCLUDED to BulkRowReason.NO_EMAIL + + alreadyPaid -> BulkRowDisposition.WARNING to BulkRowReason.ALREADY_PAID + + held.none { it.overlaps(period) } -> BulkRowDisposition.WARNING to BulkRowReason.NOT_MEMBER_IN_PERIOD + + else -> BulkRowDisposition.INCLUDED to null + } + + /** + * Their active membership where they hold one: a flag on a spell that has ended is not + * how the member pays now. Otherwise the one that started last. + */ + private fun judgedMembership(held: List): Membership? = + held.filter { it.endDate == null }.maxByOrNull { it.startDate } + ?: held.maxByOrNull { it.startDate } + + private fun latestPerMember(sends: List>): Map = sends + .groupBy { (userId, _) -> userId } + .mapValues { (_, theirs) -> theirs.maxOf { (_, at) -> at }.atZone(ZoneOffset.UTC).toLocalDate() } +} + +/** A membership running during the period. Mirrored by `overlapsContributionPeriod` in the frontend. */ +private fun Membership.overlaps(period: ContributionPeriod): Boolean = + startDate <= period.endDate && (endDate == null || endDate!! >= period.startDate) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilder.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilder.kt index 371bfa3c0..10dee17ae 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilder.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilder.kt @@ -28,7 +28,7 @@ private val MONEY_LOCALE: Locale = Locale.forLanguageTag("nl-NL") private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM yyyy", Locale.ENGLISH) -/** Both fee-cycle emails are from the same pair of officers, and answered at the same address. */ +/** Both bulk contribution emails are from the same pair of officers, and answered at the same address. */ internal const val SIGN_OFF = "Secretary & Treasurer of ESA Blueshell" internal const val REPLY_TO = "board@blueshell.utwente.nl" @@ -55,7 +55,7 @@ private fun bankTransferLines(bank: BankProperties): List = listOf( ) /** - * Fee-cycle payment request: one amount, the reason it applies, and the date it is due. + * The bulk payment request: one amount, the reason it applies, and the date it is due. * * The reason is never omitted — an amount on its own invites the reply asking why it is * that amount, which is the question the cycle exists to answer up front. @@ -105,11 +105,11 @@ fun createContributionReminderEmail( } /** - * Single-member reminder, sent from a row rather than from the cycle. + * Single-member reminder, sent from a row rather than in bulk. * * No fee type was chosen here, so the period's three options are listed rather than one - * amount quoted with a reason that would have to be guessed. Its copy is untouched by the - * fee cycle: the two are different asks, and rewriting this one was not part of the change. + * amount quoted with a reason that would have to be guessed. The two are different asks, and + * this one's copy has stayed as it was. */ fun createContributionReminderEmail( recipient: User, diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailJob.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailJob.kt index 13763c223..35e0fa1fd 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailJob.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailJob.kt @@ -13,7 +13,7 @@ import tools.jackson.databind.ObjectMapper * Renders and sends a recorded payment request. * * Which of the two reminder emails goes out is read off the record rather than decided - * here: a request written by the fee cycle carries the fee type it stated and the date it + * here: a request written by a bulk send carries the fee type it stated and the date it * asked to be paid by, so it quotes one amount and the reason for it. One written from a * single row carries neither, so it lists the period's fee options. */ diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderUseCases.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderUseCases.kt index d6bdef791..4c17f5ff1 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderUseCases.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/ContributionReminderUseCases.kt @@ -6,7 +6,7 @@ import org.springframework.stereotype.Service import net.blueshell.api.contribution.api.ContributionPeriodService /** - * Asking one member, from a row rather than from the fee cycle. + * Asking one member, from a row rather than in bulk. * * A reminder is persisted before it is sent, so a send failure leaves a record. Each call * writes its own ask: calling twice records two, which is what a treasurer chasing a member diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycle.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycle.kt deleted file mode 100644 index 055fe209d..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycle.kt +++ /dev/null @@ -1,70 +0,0 @@ -package net.blueshell.api.contribution.domain - -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.BulkRowDisposition -import net.blueshell.api.shared.dto.bulk.BulkRowReason -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.enums.MemberType -import java.time.LocalDate - -/** - * When each side of the cycle's money moves. - * - * The two travel together everywhere — the request, the send, the preview — because a cycle - * promises both or neither: whichever side a member is on, they are told a date. - */ -data class FeeCycleDates( - /** The date the transfer group is asked to have paid by. */ - val paymentDue: LocalDate, - /** The date the direct-debit group is told the money will be taken. */ - val debit: LocalDate, -) - -/** - * One member's place in a fee cycle: which side of the direct-debit partition they are on, - * what they owe and why, and whether they will be written to at all. - * - * A row that will not be written to carries its reason rather than being left out, so a - * member's absence from the send is visible instead of silent. - */ -data class FeeCycleParticipant( - val userId: Long, - val name: String, - val memberType: MemberType, - /** Start date of the membership every decision here was judged against. */ - val memberSince: LocalDate?, - val group: FeeCycleGroup, - val disposition: BulkRowDisposition, - val reason: BulkRowReason?, - /** Null exactly when no fee applies, which is when the member is honorary. */ - val feeType: BulkFeeType?, - /** Derived from [feeType] and the period. Never typed by anyone. */ - val amount: Double?, - /** When this member was last asked for this period, if they have been. */ - val lastAskedOn: LocalDate?, -) { - /** - * Whether the cycle will write to this member. - * - * `EXCLUDED` is not overridable and there is nothing else a fee cycle skips: every - * member in the plan has not paid, which is what put them there. - */ - val willSend: Boolean get() = disposition == BulkRowDisposition.INCLUDED -} - -/** - * The whole cycle for one period: every member of it who has not paid, partitioned. - * - * Preview and send are the same plan read twice, so they cannot disagree about who is - * included or what they owe. - */ -data class FeeCyclePlan( - val contributionPeriodId: Long, - val participants: List, -) { - fun group(group: FeeCycleGroup): List = participants.filter { it.group == group } - - fun byUserId(userId: Long): FeeCycleParticipant? = participants.firstOrNull { it.userId == userId } - - val recipients: List get() = participants.filter { it.willSend } -} diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycleEmailPreviewService.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycleEmailPreviewService.kt deleted file mode 100644 index 709b0e70e..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycleEmailPreviewService.kt +++ /dev/null @@ -1,84 +0,0 @@ -package net.blueshell.api.contribution.domain - -import net.blueshell.api.contribution.api.ContributionPeriodService -import net.blueshell.api.email.api.EmailPreviewRenderer -import net.blueshell.api.platform.config.BankProperties -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.email.EmailContent -import net.blueshell.api.user.api.UserService -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import org.springframework.web.server.ResponseStatusException - -/** - * One member's fee-cycle email, rendered for reading. - * - * Which of the two statements it is, and what it says, come from the same plan and the same - * builders the send uses, so changing the email changes the preview and the two cannot - * drift. Nothing here writes a record or enqueues a job: reading is free. - */ -@Service -class FeeCycleEmailPreviewService( - private val planner: FeeCyclePlanner, - private val periods: ContributionPeriodService, - private val users: UserService, - private val renderer: EmailPreviewRenderer, - private val bank: BankProperties, -) { - @Transactional(readOnly = true) - fun preview( - contributionPeriodId: Long, - userId: Long, - dates: FeeCycleDates, - feeType: BulkFeeType?, - ): FeeCycleEmailPreview { - // Through the plan rather than from the member's flag directly: which statement a - // member gets, and whether they get one at all, is the plan's answer. - val participant = planner.plan(contributionPeriodId).byUserId(userId) - ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "That member is not in this period's fee cycle") - // A member the cycle will not write to has no email to read. Refused here rather - // than left to the dialog to hide, which would render an honorary member's - // statement, or one addressed to nobody. - if (!participant.willSend) { - throw ResponseStatusException(HttpStatus.NOT_FOUND, "This cycle sends nothing to that member") - } - // A recipient always has a fee type: the only member without one is honorary, and an - // honorary member never gets past the check above. - val effectiveFeeType = feeType ?: participant.feeType!! - - val member = users.findById(userId) - val period = periods.findById(contributionPeriodId) - - // Priced here the way the send prices it, because nothing is recorded yet to read - // an amount off. - val amount = resolveFeeAmount(effectiveFeeType, period) - val content: EmailContent = when (participant.group) { - FeeCycleGroup.TRANSFER -> - createContributionReminderEmail(member, period, effectiveFeeType, amount, dates.paymentDue, bank) - FeeCycleGroup.DIRECT_DEBIT -> - createIncassoNotificationEmail(member, period, effectiveFeeType, amount, dates.debit) - } - - val rendered = renderer.render(content) - return FeeCycleEmailPreview( - group = participant.group, - feeType = effectiveFeeType, - subject = rendered.subject, - html = rendered.html, - recipientEmail = content.recipientEmail, - recipientName = content.recipientName, - ) - } -} - -/** A fee-cycle email rendered for reading, and which statement it turned out to be. */ -data class FeeCycleEmailPreview( - val group: FeeCycleGroup, - val feeType: BulkFeeType, - val subject: String, - val html: String, - val recipientEmail: String, - val recipientName: String, -) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCyclePlanner.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCyclePlanner.kt deleted file mode 100644 index 255dc5eab..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCyclePlanner.kt +++ /dev/null @@ -1,137 +0,0 @@ -package net.blueshell.api.contribution.domain - -import net.blueshell.api.contribution.api.ContributionPeriodService -import net.blueshell.api.contribution.api.ContributionService -import net.blueshell.api.contribution.persistence.ContributionPeriod -import net.blueshell.api.shared.dto.bulk.BulkRowDisposition -import net.blueshell.api.shared.dto.bulk.BulkRowReason -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.enums.MemberType -import net.blueshell.api.user.api.MembershipService -import net.blueshell.api.user.api.UserErasureService -import net.blueshell.api.user.persistence.Membership -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneOffset - -/** - * Works out who a period's fee cycle is about, and what each of them owes. - * - * The preview and the send both read this, so they cannot disagree about who is included - * or what they owe. Nothing here writes, enqueues or sends. - */ -@Service -class FeeCyclePlanner( - private val periods: ContributionPeriodService, - private val contributions: ContributionService, - private val memberships: MembershipService, - private val reminders: ContributionReminderService, - private val preNotifications: IncassoNotificationService, - private val erasure: UserErasureService, -) { - @Transactional(readOnly = true) - fun plan(contributionPeriodId: Long): FeeCyclePlan { - val period = periods.findById(contributionPeriodId) - - val paid = contributions.findByContributionPeriodId(contributionPeriodId) - .map { it.userId } - .toSet() - - // The whole population in one query. A member of the period is anyone whose - // membership overlapped it, which is the rule the manager's "member in period" - // column draws. - val judged = memberships.findOverlappingWithMembers(period.startDate, period.endDate) - .groupBy { it.userId } - .mapValues { (_, held) -> judgedMembership(held) } - - val lastAsked = lastAskedDates(contributionPeriodId) - - val participants = judged - .filterKeys { it !in paid } - .map { (userId, membership) -> participant(userId, membership, period, lastAsked) } - .sortedBy { it.name } - - return FeeCyclePlan(contributionPeriodId = contributionPeriodId, participants = participants) - } - - /** - * The membership every decision about this member is judged against. - * - * Their active one where they have it: the direct-debit flag on a membership that has - * ended is not how the member pays now, and sending the wrong statement on a stale flag - * costs them money. Where none of the memberships that put them in the period is still - * running — which is every member of a period that has closed — the one that started - * last stands in, because judging a past period by a membership that did not exist - * during it would be worse. - */ - private fun judgedMembership(held: List): Membership = - held.filter { it.endDate == null }.maxByOrNull { it.startDate } - ?: held.maxBy { it.startDate } - - private fun participant( - userId: Long, - membership: Membership, - period: ContributionPeriod, - lastAsked: Map>, - ): FeeCycleParticipant { - val member = membership.user - val feeType = resolveFeeType(membership.memberType, membership.startDate, period) - val group = if (membership.incasso) FeeCycleGroup.DIRECT_DEBIT else FeeCycleGroup.TRANSFER - - // Every exclusion here is hard: an honorary member owes nothing, a deleted account is - // nobody to write to, and an address that is not there cannot be written to. None of - // the three is a judgement the operator can overrule. - // - // Deletion is asked of the erasure snapshot rather than read off the address, which it - // anonymises to a placeholder that would pass an is-it-blank test and bounce. It does - // not end the memberships either, so without this a deleted account stays a member of - // the period and the cycle would write to it. - val (disposition, reason) = when { - membership.memberType == MemberType.HONORARY -> - BulkRowDisposition.EXCLUDED to BulkRowReason.HONORARY - erasure.isDeleted(userId) -> - BulkRowDisposition.EXCLUDED to BulkRowReason.DELETED - member.email.isBlank() -> - BulkRowDisposition.EXCLUDED to BulkRowReason.NO_EMAIL - else -> BulkRowDisposition.INCLUDED to null - } - - return FeeCycleParticipant( - userId = userId, - name = member.fullName, - memberType = membership.memberType, - memberSince = membership.startDate, - group = group, - disposition = disposition, - reason = reason, - feeType = feeType, - amount = feeType?.let { resolveFeeAmount(it, period) }, - lastAskedOn = lastAsked[group]?.get(userId), - ) - } - - /** - * When each member was last asked, per side of the partition. - * - * Read per group rather than pooled: a member moved onto direct debit part way through a - * period has been asked by transfer and not yet pre-notified, and saying otherwise would - * hide the send the treasurer is about to make. - * - * The latest of a member's asks, because there can be several — the treasurer chases, and - * each chase is its own row. - */ - private fun lastAskedDates(contributionPeriodId: Long): Map> = mapOf( - FeeCycleGroup.TRANSFER to latestAskPerMember( - reminders.findByContributionPeriodId(contributionPeriodId).map { it.userId to it.askedAt }, - ), - FeeCycleGroup.DIRECT_DEBIT to latestAskPerMember( - preNotifications.findByContributionPeriodId(contributionPeriodId).map { it.userId to it.askedAt }, - ), - ) - - private fun latestAskPerMember(asks: List>): Map = asks - .groupBy { (userId, _) -> userId } - .mapValues { (_, theirs) -> theirs.maxOf { (_, askedAt) -> askedAt }.atZone(ZoneOffset.UTC).toLocalDate() } -} diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycleUseCases.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycleUseCases.kt deleted file mode 100644 index 07e0a1866..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeCycleUseCases.kt +++ /dev/null @@ -1,140 +0,0 @@ -package net.blueshell.api.contribution.domain - -import net.blueshell.api.contribution.api.ContributionPeriodService -import net.blueshell.api.contribution.persistence.ContributionReminder -import net.blueshell.api.contribution.persistence.IncassoNotification -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.BulkSelectionRejected -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.user.api.UserService -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional -import java.time.Instant - -/** - * Asking a period's unpaid members for what they owe, in one operation. - * - * Both statements go out from one confirmation: the direct-debit group is told what will be - * debited and when, the rest are asked to transfer by a date. Which group a member is in is - * the `incasso` flag on the membership, not a choice made here. - * - * The plan is read once and used for the whole send, so what went out matches what was - * previewed, and one timestamp covers the whole cycle so it reads back as one act rather - * than a hundred moments. - * - * A cycle can be run over the same period as often as the treasurer needs: each run writes - * its own records, so asking a member in September and again in February leaves two asks - * rather than one row that only remembers the second. A fee type submitted for a member the cycle will not write to is refused - * rather than ignored: it means the operator was looking at a table that has since moved. - */ -@Service -class FeeCycleUseCases( - private val planner: FeeCyclePlanner, - private val periods: ContributionPeriodService, - private val users: UserService, - private val reminders: ContributionReminderService, - private val preNotifications: IncassoNotificationService, -) { - @Transactional(readOnly = true) - fun preview(contributionPeriodId: Long): FeeCyclePlan = planner.plan(contributionPeriodId) - - @Transactional - fun send( - contributionPeriodId: Long, - dates: FeeCycleDates, - feeTypeOverrides: Map, - ): FeeCycleResult { - val plan = planner.plan(contributionPeriodId) - rejectOverridesForNonRecipients(plan, feeTypeOverrides) - - val period = periods.findById(contributionPeriodId) - var paymentRequests = 0 - var preNotified = 0 - - val askedAt = Instant.now() - - for (participant in plan.recipients) { - // A recipient always has a fee type: the only member without one is honorary, - // and honorary members are excluded before they get here. - val feeType = feeTypeOverrides[participant.userId] ?: participant.feeType!! - val amount = resolveFeeAmount(feeType, period) - val member = users.findById(participant.userId) - - when (participant.group) { - FeeCycleGroup.TRANSFER -> { - // A row per ask. The treasurer chases, so asking again is a new record - // rather than an overwrite of the last one. - val reminder = reminders.create( - ContributionReminder( - user = member, - contributionPeriod = period, - feeType = feeType, - amount = amount, - paymentDueDate = dates.paymentDue, - askedAt = askedAt, - ), - ) - reminders.sendReminder(reminder) - paymentRequests++ - } - - FeeCycleGroup.DIRECT_DEBIT -> { - val notification = preNotifications.create( - IncassoNotification( - user = member, - contributionPeriod = period, - feeType = feeType, - amount = amount, - debitDate = dates.debit, - askedAt = askedAt, - ), - ) - preNotifications.sendNotification(notification) - preNotified++ - } - } - } - - return FeeCycleResult( - paymentRequestsQueued = paymentRequests, - preNotificationsQueued = preNotified, - excluded = plan.participants.count { !it.willSend }, - ) - } - - /** - * A fee type for somebody the cycle will not write to is a stale table, not a no-op. - * - * Applying the rest and dropping that one leaves the operator believing they changed a - * member's fee when they did not, so the whole send is refused with the ids at fault. - */ - private fun rejectOverridesForNonRecipients(plan: FeeCyclePlan, feeTypeOverrides: Map) { - val recipients = plan.recipients.map { it.userId }.toSet() - val stray = feeTypeOverrides.keys.filterNot { it in recipients }.sorted() - if (stray.isEmpty()) return - throw BulkSelectionRejected( - "SendFeeCycleRequest", - listOf( - BulkSelectionRejected.Violation( - field = "feeTypeOverrides", - code = BulkSelectionRejected.NON_RECIPIENT_FEE_TYPES, - values = stray, - message = "${stray.size} of the fee types name members this cycle does not write to.", - ), - ), - ) - } -} - -/** - * What a send did, per side of the partition. - * - * Reported separately because they are different statements: a treasurer checking a cycle - * went out needs to know both halves happened, and a total of the two hides one being zero. - */ -data class FeeCycleResult( - val paymentRequestsQueued: Int, - val preNotificationsQueued: Int, - /** Members in the cycle who were not written to, with their reasons visible in the preview. */ - val excluded: Int, -) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeResolution.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeResolution.kt index dd006dced..28fa81cf4 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeResolution.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/domain/FeeResolution.kt @@ -9,7 +9,7 @@ import java.time.LocalDate * Fee type applicable to a member, or null for HONORARY members who are excluded. * * The cutoff is the period's own [ContributionPeriod.halfYearCutoffDate] rather than an - * argument, so a fee cycle run in February and one run in June cannot price the same + * argument, so a send made in February and one made in June cannot price the same * membership differently. A REGULAR membership starting after the cutoff pays the * half-year fee; one starting on the cutoff itself pays the full year. */ @@ -46,7 +46,7 @@ fun resolveFeeAmount(feeType: BulkFeeType, period: ContributionPeriod): Double = /** * Human-readable reason for why a specific [BulkFeeType] applies to a member, stated - * inline in fee-cycle emails so the amount is never quoted without context. + * inline in both bulk contribution emails so the amount is never quoted without context. */ fun feeReason(feeType: BulkFeeType): String = when (feeType) { BulkFeeType.ALUMNI_FEE -> "the alumni fee, as you are an alumni member" diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/persistence/ContributionReminder.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/persistence/ContributionReminder.kt index 461c7d170..9968cd576 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/persistence/ContributionReminder.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/persistence/ContributionReminder.kt @@ -47,9 +47,9 @@ class ContributionReminder( /** * The fee type this request stated, so the email's reason is the true one rather than a - * guess recovered from an amount. Null on the rows written before the fee cycle existed, - * and by the single-member reminder, which quotes the period's fee options instead of one - * amount and therefore states no single reason. + * guess recovered from an amount. Null on rows written before the type was stored, and by + * the single-member reminder, which quotes the period's fee options instead of one amount + * and therefore states no single reason. */ @Enumerated(EnumType.STRING) @Column(name = "fee_type", length = 32) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailController.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailController.kt new file mode 100644 index 000000000..89c14f371 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailController.kt @@ -0,0 +1,105 @@ +package net.blueshell.api.contribution.web + +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import net.blueshell.api.contribution.domain.BulkContributionEmailUseCases +import net.blueshell.api.contribution.domain.ContributionEmailKind +import net.blueshell.api.contribution.domain.ContributionEmailMessage +import net.blueshell.api.contribution.domain.ContributionEmailMessageService +import net.blueshell.api.contribution.domain.ContributionEmailPlan +import net.blueshell.api.contribution.domain.ContributionEmailResult +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate + +/** + * A period's payment emails: read what a send would do, read one member's email, then send. + * Every endpoint needs write on both records, because one send writes both. + */ +@RestController +@Tag(name = "Contributions") +class BulkContributionEmailController( + private val useCases: BulkContributionEmailUseCases, + private val messages: ContributionEmailMessageService, +) { + @PreAuthorize(BOTH_STATEMENTS) + @PostMapping("/contributions/bulk/email/preview") + fun previewBulkContributionEmail( + @Valid @RequestBody request: BulkContributionEmailPreviewRequest, + ): BulkContributionEmailPreviewResponse = + useCases.preview( + contributionPeriodId = requireNotNull(request.contributionPeriodId), + userIds = request.userIds, + ).asResponse() + + /** Reading sends nothing and records nothing. */ + @PreAuthorize(BOTH_STATEMENTS) + @GetMapping("/contributions/bulk/email/message") + fun readContributionEmail( + @RequestParam kind: ContributionEmailKind, + @RequestParam contributionPeriodId: Long, + @RequestParam userId: Long, + @RequestParam date: LocalDate, + @RequestParam(required = false) feeType: BulkFeeType?, + ): ContributionEmailMessageResponse = + messages.render(kind, contributionPeriodId, userId, date, feeType).asResponse() + + @PreAuthorize(BOTH_STATEMENTS) + @PostMapping("/contributions/bulk/email/send") + fun sendPaymentEmails(@Valid @RequestBody request: SendPaymentEmailsRequest): PaymentEmailsResultResponse = + useCases.send( + contributionPeriodId = requireNotNull(request.contributionPeriodId), + userIds = request.userIds, + forciblyIncluded = request.forciblyIncludedUserIds.toSet(), + kindOverrides = request.kindOverrides, + paymentDueDate = request.paymentDueDate, + debitDate = request.debitDate, + feeTypeOverrides = request.feeTypeOverrides, + ).asResponse() + + private companion object { + const val BOTH_STATEMENTS = + "hasPermission('__NO_TARGET__', 'ContributionReminder', 'write') " + + "and hasPermission('__NO_TARGET__', 'IncassoNotification', 'write')" + } +} + +private fun ContributionEmailPlan.asResponse() = BulkContributionEmailPreviewResponse( + contributionPeriodId = contributionPeriodId, + rows = rows.map { row -> + BulkContributionEmailRowResponse( + userId = row.userId, + name = row.name, + memberType = row.memberType, + memberSince = row.memberSince, + disposition = row.disposition, + reason = row.reason, + defaultKind = row.defaultKind, + feeType = row.feeType, + amount = row.amount, + lastRemindedOn = row.lastRemindedOn, + lastNotifiedOn = row.lastNotifiedOn, + ) + }, + unknownUserIds = unknownUserIds, +) + +private fun ContributionEmailMessage.asResponse() = ContributionEmailMessageResponse( + kind = kind, + feeType = feeType, + subject = subject, + html = html, + recipientEmail = recipientEmail, + recipientName = recipientName, +) + +private fun ContributionEmailResult.asResponse() = PaymentEmailsResultResponse( + remindersSent = remindersSent, + incassoNotificationsSent = incassoNotificationsSent, + notWrittenTo = notWrittenTo, +) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailRequest.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailRequest.kt new file mode 100644 index 000000000..5382680fc --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailRequest.kt @@ -0,0 +1,62 @@ +package net.blueshell.api.contribution.web + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Future +import jakarta.validation.constraints.NotEmpty +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Positive +import jakarta.validation.constraints.Size +import net.blueshell.api.contribution.domain.ContributionEmailKind +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import java.time.LocalDate + +private const val MAX_SELECTION = 1000 + +@Schema(name = "BulkContributionEmailPreviewRequest") +data class BulkContributionEmailPreviewRequest( + @field:NotNull(message = "A contribution period is required.") + @field:Positive + val contributionPeriodId: Long? = null, + + @field:NotEmpty(message = "Select at least one member.") + @field:Size(max = MAX_SELECTION, message = "Select at most $MAX_SELECTION members.") + val userIds: List<@Positive Long> = emptyList(), +) + +/** + * Two things can be stated per member and nothing else: which email they get, and which fee + * *type* prices it. Never an amount — that follows from the type and the period. + */ +@Schema(name = "SendPaymentEmailsRequest") +data class SendPaymentEmailsRequest( + @field:NotNull(message = "A contribution period is required.") + @field:Positive + val contributionPeriodId: Long? = null, + + @field:NotEmpty(message = "Select at least one member.") + @field:Size(max = MAX_SELECTION, message = "Select at most $MAX_SELECTION members.") + val userIds: List<@Positive Long> = emptyList(), + + @field:Size(max = MAX_SELECTION, message = "Tick at most $MAX_SELECTION members back in.") + @field:Schema( + description = "Warned members this request overrules. Every one of them must be in the " + + "selection and must be somebody the send writes to.", + ) + val forciblyIncludedUserIds: List<@Positive Long> = emptyList(), + + @field:Size(max = MAX_SELECTION, message = "Choose an email for at most $MAX_SELECTION members.") + @field:Schema(description = "Members moved off the email their direct-debit flag chose.") + val kindOverrides: Map = emptyMap(), + + @field:Future(message = "A payment due date must be after today.") + @field:Schema(description = "Required exactly when somebody here is getting a payment request.") + val paymentDueDate: LocalDate? = null, + + @field:Future(message = "A debit date must be after today.") + @field:Schema(description = "Required exactly when somebody here is getting a pre-notification.") + val debitDate: LocalDate? = null, + + @field:Size(max = MAX_SELECTION, message = "Choose a fee for at most $MAX_SELECTION members.") + @field:Schema(description = "Fee type per member, where the treasurer changed it.") + val feeTypeOverrides: Map = emptyMap(), +) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailResponse.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailResponse.kt new file mode 100644 index 000000000..f7c9be088 --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/BulkContributionEmailResponse.kt @@ -0,0 +1,65 @@ +package net.blueshell.api.contribution.web + +import io.swagger.v3.oas.annotations.media.Schema +import net.blueshell.api.contribution.domain.ContributionEmailKind +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.dto.bulk.BulkRowDisposition +import net.blueshell.api.shared.dto.bulk.BulkRowReason +import net.blueshell.api.shared.enums.MemberType +import java.time.LocalDate + +@Schema(name = "BulkContributionEmailRowResponse") +data class BulkContributionEmailRowResponse( + val userId: Long, + val name: String, + val memberType: MemberType, + + @field:Schema(description = "Start of the membership every decision on this row was judged against.") + val memberSince: LocalDate?, + + val disposition: BulkRowDisposition, + + @field:Schema(description = "Why this member is warned about or not written to.") + val reason: BulkRowReason?, + + @field:Schema(description = "The email this member gets unless the treasurer switches it.") + val defaultKind: ContributionEmailKind, + + @field:Schema(description = "Absent only for honorary members, who owe nothing.") + val feeType: BulkFeeType?, + + @field:Schema(description = "Follows from the fee type and the period. Never typed.") + val amount: Double?, + + val lastRemindedOn: LocalDate?, + val lastNotifiedOn: LocalDate?, +) + +/** A member who will not be written to is present with their reason rather than left out. */ +@Schema(name = "BulkContributionEmailPreviewResponse") +data class BulkContributionEmailPreviewResponse( + val contributionPeriodId: Long, + val rows: List, + + @field:Schema(description = "Selected ids that resolve to nobody. No row is drawn, and the send refuses them.") + val unknownUserIds: List, +) + +@Schema(name = "ContributionEmailMessageResponse") +data class ContributionEmailMessageResponse( + val kind: ContributionEmailKind, + val feeType: BulkFeeType, + val subject: String, + val html: String, + val recipientEmail: String, + val recipientName: String, +) + +@Schema(name = "PaymentEmailsResultResponse") +data class PaymentEmailsResultResponse( + val remindersSent: Int, + val incassoNotificationsSent: Int, + + @field:Schema(description = "Members in the selection who were not written to.") + val notWrittenTo: Int, +) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/FeeCycleController.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/FeeCycleController.kt deleted file mode 100644 index a6c072f97..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/FeeCycleController.kt +++ /dev/null @@ -1,111 +0,0 @@ -package net.blueshell.api.contribution.web - -import io.swagger.v3.oas.annotations.tags.Tag -import jakarta.validation.Valid -import net.blueshell.api.contribution.domain.FeeCycleEmailPreview -import net.blueshell.api.contribution.domain.FeeCycleDates -import net.blueshell.api.contribution.domain.FeeCycleEmailPreviewService -import net.blueshell.api.contribution.domain.FeeCyclePlan -import net.blueshell.api.contribution.domain.FeeCycleResult -import net.blueshell.api.contribution.domain.FeeCycleUseCases -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import java.time.LocalDate -import org.springframework.security.access.prepost.PreAuthorize -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -/** - * The fee cycle for one contribution period: read it, then send it. - * - * Board-only, matching the two records it writes. Both endpoints are anchored to a period — - * there is no cycle without one, which is why neither takes a list of members. - */ -@RestController -@Tag(name = "Contributions") -class FeeCycleController( - private val useCases: FeeCycleUseCases, - private val emailPreviews: FeeCycleEmailPreviewService, -) { - @PreAuthorize( - "hasPermission('__NO_TARGET__', 'ContributionReminder', 'write') " + - "and hasPermission('__NO_TARGET__', 'IncassoNotification', 'write')", - ) - @GetMapping("/contributions/fee-cycle") - fun previewFeeCycle(@RequestParam contributionPeriodId: Long): FeeCyclePreviewResponse = - useCases.preview(contributionPeriodId).asResponse() - - @PreAuthorize( - "hasPermission('__NO_TARGET__', 'ContributionReminder', 'write') " + - "and hasPermission('__NO_TARGET__', 'IncassoNotification', 'write')", - ) - @PostMapping("/contributions/fee-cycle/send") - fun sendFeeCycle(@Valid @RequestBody request: SendFeeCycleRequest): FeeCycleResultResponse = - useCases.send( - contributionPeriodId = requireNotNull(request.contributionPeriodId), - dates = FeeCycleDates( - paymentDue = requireNotNull(request.paymentDueDate), - debit = requireNotNull(request.debitDate), - ), - feeTypeOverrides = request.feeTypeOverrides, - ).asResponse() - - /** - * One member's email, as they would receive it. Reading it sends nothing and records - * nothing, and which of the two statements comes back is the member's own side of the - * partition rather than a parameter. - */ - @PreAuthorize( - "hasPermission('__NO_TARGET__', 'ContributionReminder', 'write') " + - "and hasPermission('__NO_TARGET__', 'IncassoNotification', 'write')", - ) - @GetMapping("/contributions/fee-cycle/email-preview") - fun previewFeeCycleEmail( - @RequestParam contributionPeriodId: Long, - @RequestParam userId: Long, - @RequestParam paymentDueDate: LocalDate, - @RequestParam debitDate: LocalDate, - @RequestParam(required = false) feeType: BulkFeeType?, - ): FeeCycleEmailPreviewResponse = - emailPreviews.preview( - contributionPeriodId, - userId, - FeeCycleDates(paymentDue = paymentDueDate, debit = debitDate), - feeType, - ).asResponse() -} - -private fun FeeCycleEmailPreview.asResponse(): FeeCycleEmailPreviewResponse = FeeCycleEmailPreviewResponse( - group = group, - feeType = feeType, - subject = subject, - html = html, - recipientEmail = recipientEmail, - recipientName = recipientName, -) - -private fun FeeCyclePlan.asResponse(): FeeCyclePreviewResponse = FeeCyclePreviewResponse( - contributionPeriodId = contributionPeriodId, - rows = participants.map { participant -> - FeeCycleRowResponse( - userId = participant.userId, - name = participant.name, - memberType = participant.memberType, - memberSince = participant.memberSince, - group = participant.group, - disposition = participant.disposition, - reason = participant.reason, - feeType = participant.feeType, - amount = participant.amount, - lastAskedOn = participant.lastAskedOn, - ) - }, -) - -private fun FeeCycleResult.asResponse(): FeeCycleResultResponse = FeeCycleResultResponse( - paymentRequestsQueued = paymentRequestsQueued, - preNotificationsQueued = preNotificationsQueued, - excluded = excluded, -) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/FeeCycleResponse.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/FeeCycleResponse.kt deleted file mode 100644 index 498de4dca..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/FeeCycleResponse.kt +++ /dev/null @@ -1,80 +0,0 @@ -package net.blueshell.api.contribution.web - -import io.swagger.v3.oas.annotations.media.Schema -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.BulkRowDisposition -import net.blueshell.api.shared.dto.bulk.BulkRowReason -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.enums.MemberType -import java.time.LocalDate - -/** One member's row in the fee cycle: which side they are on, what they owe and why. */ -@Schema(name = "FeeCycleRowResponse") -data class FeeCycleRowResponse( - val userId: Long, - val name: String, - val memberType: MemberType, - - @field:Schema(description = "Start of the membership every decision on this row was judged against.") - val memberSince: LocalDate?, - - @field:Schema(description = "Decided by the member's direct-debit flag, not by the operator.") - val group: FeeCycleGroup, - - val disposition: BulkRowDisposition, - - @field:Schema(description = "Why this member is not written to. Absent on an included row.") - val reason: BulkRowReason?, - - @field:Schema(description = "Absent only for honorary members, who owe nothing.") - val feeType: BulkFeeType?, - - @field:Schema(description = "Follows from the fee type and the period. Never typed.") - val amount: Double?, - - @field:Schema(description = "When this member was last asked for this period, on this side of the partition.") - val lastAskedOn: LocalDate?, -) - -/** - * The whole cycle for one period: every member of it who has not paid. - * - * Rows that will not be written to are present with their reason rather than left out, so a - * member's absence from the send is visible. - */ -@Schema(name = "FeeCyclePreviewResponse") -data class FeeCyclePreviewResponse( - val contributionPeriodId: Long, - val rows: List, -) - -/** What a send did, per side of the partition. */ -@Schema(name = "FeeCycleResultResponse") -data class FeeCycleResultResponse( - val paymentRequestsQueued: Int, - val preNotificationsQueued: Int, - - @field:Schema(description = "Members in the cycle who were not written to.") - val excluded: Int, -) - -/** - * A fee-cycle email as the member would receive it, and which of the two statements it is. - * - * Rendered through the shared preview renderer from the same `EmailContent` the send path - * builds, with hosted images inlined so the pictures show in a browser that cannot reach - * the asset host. - */ -@Schema(name = "FeeCycleEmailPreviewResponse") -data class FeeCycleEmailPreviewResponse( - @field:Schema(description = "Which statement this member receives, decided by their direct-debit flag.") - val group: FeeCycleGroup, - - @field:Schema(description = "The fee type the email states, and the reason it gives.") - val feeType: BulkFeeType, - - val subject: String, - val html: String, - val recipientEmail: String, - val recipientName: String, -) diff --git a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/SendFeeCycleRequest.kt b/services/api/src/main/kotlin/net/blueshell/api/contribution/web/SendFeeCycleRequest.kt deleted file mode 100644 index 4b2372d28..000000000 --- a/services/api/src/main/kotlin/net/blueshell/api/contribution/web/SendFeeCycleRequest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package net.blueshell.api.contribution.web - -import io.swagger.v3.oas.annotations.media.Schema -import jakarta.validation.constraints.NotNull -import jakarta.validation.constraints.Positive -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import java.time.LocalDate - -/** - * Opens the fee cycle for a period: both dates, and any fee type the treasurer changed. - * - * There is no list of members and no list of amounts. Who is asked follows from the period, - * and what they owe follows from the fee type and the period's fees — so the only thing the - * treasurer can state here is a different *type* for a member, never a different amount. - */ -@Schema(name = "SendFeeCycleRequest") -data class SendFeeCycleRequest( - @field:NotNull(message = "A contribution period is required.") - @field:Positive - val contributionPeriodId: Long? = null, - - @field:NotNull(message = "A payment due date is required.") - @field:Schema(description = "The date the transfer group is asked to have paid by.") - val paymentDueDate: LocalDate? = null, - - @field:NotNull(message = "A debit date is required.") - @field:Schema(description = "The date the direct-debit group is told the money will be taken.") - val debitDate: LocalDate? = null, - - @field:Schema(description = "Fee type per member, where the treasurer changed it from the one that applies.") - val feeTypeOverrides: Map = emptyMap(), -) diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ValidationProblemDetailsAdvice.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ValidationProblemDetailsAdvice.kt index ce501a52e..96cb8b2d5 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ValidationProblemDetailsAdvice.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/config/advice/ValidationProblemDetailsAdvice.kt @@ -3,6 +3,7 @@ package net.blueshell.api.platform.config.advice import jakarta.servlet.http.HttpServletRequest import jakarta.validation.ConstraintViolation import jakarta.validation.ConstraintViolationException +import net.blueshell.api.shared.dto.bulk.BulkFieldRejected import org.slf4j.MDC import org.springframework.core.Ordered import org.springframework.core.annotation.Order @@ -91,6 +92,27 @@ class ValidationProblemDetailsAdvice { return pd } + /** + * A rule that needed the database, reported in the same `errors` shape as the bean + * constraints above so the client attaches it to the field without a special case. + */ + @ExceptionHandler(BulkFieldRejected::class) + fun handleBulkFieldRejected( + ex: BulkFieldRejected, + request: HttpServletRequest + ): ProblemDetail { + val pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed for request.") + pd.instance = URI.create(request.requestURI) + pd.setProperty( + "errors", + ex.violations.map { errorMap(ex.objectName, it.field, it.message, it.code) } + ) + val traceId = MDC.get("traceId") + if (traceId != null) pd.setProperty("traceId", traceId) + + return pd + } + @ExceptionHandler(ConstraintViolationException::class) fun handleConstraintViolation( ex: ConstraintViolationException, diff --git a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/InMemoryEmailClient.kt b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/InMemoryEmailClient.kt index 4c8365b79..ba9ae6e38 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/InMemoryEmailClient.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/platform/integration/mock/InMemoryEmailClient.kt @@ -4,6 +4,7 @@ import net.blueshell.api.email.domain.EmailTransportClient import org.springframework.context.annotation.Primary import org.springframework.context.annotation.Profile import org.springframework.stereotype.Component +import java.util.concurrent.CopyOnWriteArrayList /** * In-memory capture mock of [EmailTransportClient] used in the test profile. @@ -17,7 +18,10 @@ import org.springframework.stereotype.Component @Profile("test") class InMemoryEmailClient : EmailTransportClient { - private val _sentEmails = mutableListOf() + // Thread-safe because email jobs run async, and a send that queues two at once has two + // threads adding at the same moment. A plain ArrayList loses one of them, and the loss + // reads as an email that was never sent. + private val _sentEmails = CopyOnWriteArrayList() val sentEmails: List get() = _sentEmails.toList() @Volatile diff --git a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkActionEnvelope.kt b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkActionEnvelope.kt index 4c8e7a6a8..0587ff29a 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkActionEnvelope.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkActionEnvelope.kt @@ -27,21 +27,6 @@ enum class BulkFeeType { ALUMNI_FEE, } -/** - * Which side of the fee cycle a member is on. - * - * The `incasso` flag on the member's membership decides it, so this is not a choice the - * operator makes: one group is told what will be debited, the other is asked to transfer. - */ -@Schema(name = "FeeCycleGroup", enumAsRef = true) -enum class FeeCycleGroup { - /** Pays by direct debit, and is told what will be taken and when. */ - DIRECT_DEBIT, - - /** Pays by transfer, and is asked to pay what is owed by when. */ - TRANSFER, -} - /** How a selected user will be treated by a bulk action. */ @Schema(name = "BulkRowDisposition", enumAsRef = true) enum class BulkRowDisposition { @@ -65,31 +50,24 @@ enum class BulkRowReason { NOT_PAID, HONORARY, - /** - * Dead: nothing sets it. It existed because the payment request and the pre-notification - * were two independent sends, so a member could be selected for the wrong one. Under the - * fee cycle the `incasso` flag decides which side of the partition a member is on, so - * there is no wrong one and not having the flag is not a warning. Kept for one release so - * a client reading a stored value still recognises it. - */ - @Deprecated("Nothing populates this. The fee-cycle partition replaced it.") + /** Dead: routing by the direct-debit flag replaced it. Kept one release for stored values. */ + @Deprecated("Nothing populates this.") INCASSO_MISMATCH, + + /** No membership running right now. Read by the membership actions. */ NO_ACTIVE_MEMBERSHIP, - STARTED_TODAY, /** - * Email actions (reminder/incasso): the user has no email address on file, so - * nothing can be sent. Previously the execute handler skipped these silently and - * the preview never surfaced it — now it is a first-class SKIPPED reason visible - * in the preview. See docs/flows/fee-cycle/README.md. + * Held no membership overlapping the period being billed. Distinct from + * [NO_ACTIVE_MEMBERSHIP]: a current member can still not have been here in 2024. */ + NOT_MEMBER_IN_PERIOD, + STARTED_TODAY, + + /** No address on file, so there is nothing to send to. */ NO_EMAIL, - /** - * The account has been deleted. Deletion anonymises the address to a placeholder and - * keeps the row for a restore window, and it does not end the memberships — so a deleted - * account still looks like a member of a period, and an action that reads memberships has - * to say so rather than write to it. - */ + + /** Deletion anonymises the address and leaves the memberships running, so it must be asked. */ DELETED, /** Resume/start-new: the user already has an active (endDate=null) membership. */ ALREADY_ACTIVE, diff --git a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkFieldRejected.kt b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkFieldRejected.kt new file mode 100644 index 000000000..73807f92e --- /dev/null +++ b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkFieldRejected.kt @@ -0,0 +1,37 @@ +package net.blueshell.api.shared.dto.bulk + +/** + * A field on a bulk request refused by a rule that needed the database, and so could not be + * a bean constraint. + * + * 400 rather than the 409 [BulkSelectionRejected] carries: the request is wrong rather than + * stale, so the caller corrects the field instead of re-reading the selection. The reasons + * travel in the same `errors` shape bean validation produces, so one client-side handler + * covers every refusal in a flow. + */ +class BulkFieldRejected( + val objectName: String, + val violations: List, +) : RuntimeException( + "Bulk request rejected: " + violations.joinToString("; ") { "${it.field}=${it.code}" }, +) { + /** + * @param field the request field at fault, so a form can attach the message to it + * @param code stable identifier the client branches on; never a display string + * @param message fixed per code — it interpolates nothing, so it carries no presentation + * decision and stays readable in a log + */ + data class Violation( + val field: String, + val code: String, + val message: String, + ) + + companion object { + /** Somebody in the batch gets an email that states this date, and it is absent. */ + const val DATE_REQUIRED: String = "DateRequired" + + /** The date falls before the contribution period starts, or too long after it ends. */ + const val DATE_OUTSIDE_PERIOD: String = "DateOutsideContributionPeriod" + } +} diff --git a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkRowVocabulary.kt b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkRowVocabulary.kt index d4d5474bd..36b5a339a 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkRowVocabulary.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkRowVocabulary.kt @@ -18,5 +18,4 @@ data class BulkRowVocabulary( val disposition: BulkRowDisposition, val reason: BulkRowReason, val feeType: BulkFeeType, - val feeCycleGroup: FeeCycleGroup, ) diff --git a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkSelectionRejected.kt b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkSelectionRejected.kt index 916a90786..3c0caf3ba 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkSelectionRejected.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkSelectionRejected.kt @@ -51,12 +51,27 @@ class BulkSelectionRejected( const val HONORARY_USERS: String = "HonoraryUserIds" /** - * A fee type was submitted for a member the fee cycle does not write to. The table + * The selection names the same user twice. Refused rather than collapsed, so a + * client that has lost count of its own rows says so instead of being absorbed. + */ + const val DUPLICATE_USERS: String = "DuplicateUserIds" + + /** A forced id naming a user the action still does not write to. */ + const val NON_RECIPIENT_FORCED: String = "NonRecipientForcedUserIds" + + /** A forced id the selection does not name at all. */ + const val UNKNOWN_FORCED: String = "UnknownForcedUserIds" + + /** + * A fee type was submitted for a member the send does not write to. The table * the operator was looking at has moved, so the send is refused rather than applied * to the members it still recognises. */ const val NON_RECIPIENT_FEE_TYPES: String = "NonRecipientFeeTypeUserIds" + /** An email was chosen for a member the send does not write to. */ + const val NON_RECIPIENT_EMAIL_KINDS: String = "NonRecipientEmailKindUserIds" + /** The selection names a contribution period that no longer exists. */ const val UNKNOWN_PERIOD: String = "UnknownContributionPeriodId" diff --git a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkUserSelection.kt b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkUserSelection.kt index 5f99d70ab..a4539a807 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkUserSelection.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/shared/dto/bulk/BulkUserSelection.kt @@ -28,16 +28,7 @@ object BulkUserSelection { val deleted = userIds.filterNot { it in unknown }.filter(isDeleted) val violations = buildList { - if (unknown.isNotEmpty()) { - add( - BulkSelectionRejected.Violation( - field = "userIds", - code = BulkSelectionRejected.UNKNOWN_USERS, - values = unknown, - message = "${unknown.size} of the selected users no longer exist.", - ), - ) - } + if (unknown.isNotEmpty()) add(unknownUsers(unknown)) if (deleted.isNotEmpty()) { add( BulkSelectionRejected.Violation( @@ -52,6 +43,15 @@ object BulkUserSelection { return Classified(usable = userIds.filterNot { it in unknown || it in deleted }, violations = violations) } + /** Ids that were never users, refused the same way whichever action found them. */ + fun unknownUsers(userIds: List): BulkSelectionRejected.Violation = + BulkSelectionRejected.Violation( + field = "userIds", + code = BulkSelectionRejected.UNKNOWN_USERS, + values = userIds, + message = "${userIds.size} of the selected users no longer exist.", + ) + /** * @param usable the ids that resolve to a live user, which is all an action can judge * its own rules against diff --git a/services/api/src/main/kotlin/net/blueshell/api/user/api/MembershipService.kt b/services/api/src/main/kotlin/net/blueshell/api/user/api/MembershipService.kt index ccf4a7609..cf0e550e5 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/user/api/MembershipService.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/user/api/MembershipService.kt @@ -109,10 +109,12 @@ class MembershipService @Autowired constructor( fun findUserIdsOverlapping(from: LocalDate, to: LocalDate): Set = repository.findUserIdsOverlapping(from, to).toSet() - /** - * Every membership that overlapped the window, with its member loaded. The fee cycle - * reads its whole population from this rather than a query per member. - */ + /** Memberships held by any of these users, grouped per user, with each member loaded. */ + @Transactional(readOnly = true) + fun findByUserIdsWithMembers(userIds: Collection): Map> = + if (userIds.isEmpty()) emptyMap() else repository.findByUserIdsWithMembers(userIds).groupBy { it.userId } + + /** Every membership that overlapped the window, with its member loaded. */ @Transactional(readOnly = true) fun findOverlappingWithMembers(from: LocalDate, to: LocalDate): List = repository.findOverlappingWithMembers(from, to) diff --git a/services/api/src/main/kotlin/net/blueshell/api/user/persistence/MemberRepository.kt b/services/api/src/main/kotlin/net/blueshell/api/user/persistence/MemberRepository.kt index d8fa8ca3f..e75f0eb65 100644 --- a/services/api/src/main/kotlin/net/blueshell/api/user/persistence/MemberRepository.kt +++ b/services/api/src/main/kotlin/net/blueshell/api/user/persistence/MemberRepository.kt @@ -20,6 +20,20 @@ interface MemberRepository : BaseRepository { */ fun findByUser_IdIn(userIds: Collection): MutableList + /** + * [findByUser_IdIn] with the member fetched alongside. `User.memberProfile` is an eager + * `mappedBy` one-to-one, so lazy members cost two queries each. + */ + @Query( + """ + SELECT m FROM Membership m + JOIN FETCH m.user u + LEFT JOIN FETCH u.memberProfile + WHERE m.user.id IN :userIds + """, + ) + fun findByUserIdsWithMembers(@Param("userIds") userIds: Collection): List + /** * Everybody whose membership overlapped the window, whatever kind of membership it was. * A membership with no end date is still running, so it overlaps anything from its start diff --git a/services/api/src/main/resources/db/migration/V99__contribution_period_half_year_cutoff.sql b/services/api/src/main/resources/db/migration/V99__contribution_period_half_year_cutoff.sql index ba985edc5..dc7a568db 100644 --- a/services/api/src/main/resources/db/migration/V99__contribution_period_half_year_cutoff.sql +++ b/services/api/src/main/resources/db/migration/V99__contribution_period_half_year_cutoff.sql @@ -1,7 +1,7 @@ -- The half-year cutoff becomes policy for the year rather than an input retyped on every send. -- -- A regular membership starting after this date pays the half-year fee; one starting on it or --- before pays the full year. Living on the period is what stops a fee cycle in February and one +-- before pays the full year. Living on the period is what stops a send made in February and one -- in June from silently applying different cutoffs. ALTER TABLE contribution_periods ADD COLUMN half_year_cutoff_date DATE NULL AFTER end_date; diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/BulkContributionEmailUseCasesTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/BulkContributionEmailUseCasesTest.kt new file mode 100644 index 000000000..1e7b905ef --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/BulkContributionEmailUseCasesTest.kt @@ -0,0 +1,424 @@ +package net.blueshell.api.contribution.domain + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import net.blueshell.api.contribution.api.ContributionPeriodService +import net.blueshell.api.contribution.persistence.ContributionPeriod +import net.blueshell.api.contribution.persistence.ContributionReminder +import net.blueshell.api.contribution.persistence.IncassoNotification +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.dto.bulk.BulkFieldRejected +import net.blueshell.api.shared.dto.bulk.BulkRowDisposition +import net.blueshell.api.shared.dto.bulk.BulkRowReason +import net.blueshell.api.shared.dto.bulk.BulkSelectionRejected +import net.blueshell.api.shared.enums.MemberType +import net.blueshell.api.user.api.UserService +import net.blueshell.api.user.persistence.User +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate + +/** What a payment-email send does with the plan it is handed. */ +class BulkContributionEmailUseCasesTest { + + private val periodId = 7L + private val dueDate = LocalDate.of(2026, 3, 1) + private val debitDate = LocalDate.of(2026, 3, 15) + + private val period = ContributionPeriod( + startDate = LocalDate.of(2025, 9, 1), + endDate = LocalDate.of(2026, 8, 31), + halfYearCutoffDate = LocalDate.of(2026, 2, 1), + halfYearFee = 25.0, + fullYearFee = 45.0, + alumniFee = 10.0, + ).seeded(periodId) + + private val planner: ContributionEmailPlanner = mockk() + private val periods: ContributionPeriodService = mockk() + private val users: UserService = mockk() + private val reminders: ContributionReminderService = mockk(relaxed = true) + private val preNotifications: IncassoNotificationService = mockk(relaxed = true) + + private val useCases = + BulkContributionEmailUseCases(planner, periods, users, reminders, preNotifications) + + @Nested + inner class OneConfirmationSendsBoth { + + @Test + fun `each statement is written and reported separately`() { + plan( + row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION), + row(2L, "Ben Transfer", ContributionEmailKind.REMINDER), + row(3L, "Cara Transfer", ContributionEmailKind.REMINDER), + ) + + val result = send() + + assertThat(result.remindersSent).isEqualTo(2) + assertThat(result.incassoNotificationsSent).isEqualTo(1) + assertThat(result.notWrittenTo).isEqualTo(0) + } + + @Test + fun `a payment request records what it asked for and when`() { + plan(row(1L, "Ben Transfer", ContributionEmailKind.REMINDER)) + val written = slot() + every { reminders.create(capture(written)) } answers { written.captured } + + send() + + assertThat(written.captured.feeType).isEqualTo(BulkFeeType.FULL_YEAR_FEE) + assertThat(written.captured.amount).isEqualTo(45.0) + assertThat(written.captured.paymentDueDate).isEqualTo(dueDate) + } + + @Test + fun `a pre-notification records what will be taken and when`() { + plan(row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION)) + val written = slot() + every { preNotifications.create(capture(written)) } answers { written.captured } + + send() + + assertThat(written.captured.amount).isEqualTo(45.0) + assertThat(written.captured.debitDate).isEqualTo(debitDate) + } + + @Test + fun `every recipient's email is queued`() { + plan( + row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION), + row(2L, "Ben Transfer", ContributionEmailKind.REMINDER), + ) + + send() + + verify(exactly = 1) { reminders.sendReminder(any()) } + verify(exactly = 1) { preNotifications.sendNotification(any()) } + } + } + + @Nested + inner class MovingAMemberOntoTheOtherEmail { + + @Test + fun `a switched member gets the statement the treasurer chose`() { + plan(row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION)) + + val result = send(kindOverrides = mapOf(1L to ContributionEmailKind.REMINDER)) + + assertThat(result.remindersSent).isEqualTo(1) + assertThat(result.incassoNotificationsSent).isEqualTo(0) + } + + @Test + fun `choosing an email for somebody the send skips refuses the whole thing`() { + plan( + row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION), + row(4L, "Dan Honorary", ContributionEmailKind.REMINDER, BulkRowDisposition.EXCLUDED, BulkRowReason.HONORARY), + ) + + assertThatThrownBy { send(kindOverrides = mapOf(4L to ContributionEmailKind.REMINDER)) } + .isInstanceOf(BulkSelectionRejected::class.java) + .satisfies({ + val violation = (it as BulkSelectionRejected).violations.single() + assertThat(violation.code).isEqualTo(BulkSelectionRejected.NON_RECIPIENT_EMAIL_KINDS) + assertThat(violation.values).containsExactly(4L) + }) + verify(exactly = 0) { reminders.create(any()) } + } + } + + @Nested + inner class WhoTheSendWritesTo { + + @Test + fun `a warned member is skipped until they are ticked back in`() { + plan( + row(1L, "Ann Transfer", ContributionEmailKind.REMINDER), + row(2L, "Ben Paid", ContributionEmailKind.REMINDER, BulkRowDisposition.WARNING, BulkRowReason.ALREADY_PAID), + ) + + assertThat(send().remindersSent).isEqualTo(1) + assertThat(send(forciblyIncluded = setOf(2L)).remindersSent).isEqualTo(2) + } + + @Test + fun `a hard-excluded member is counted as not written to, not as sent`() { + plan( + row(1L, "Ann Transfer", ContributionEmailKind.REMINDER), + row(4L, "Dan Honorary", ContributionEmailKind.REMINDER, BulkRowDisposition.EXCLUDED, BulkRowReason.HONORARY), + ) + + val result = send() + + assertThat(result.remindersSent).isEqualTo(1) + assertThat(result.notWrittenTo).isEqualTo(1) + } + } + + @Nested + inner class WhenTheSelectionDoesNotAddUp { + + @Test + fun `an id naming nobody is refused rather than dropped`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertRefusal( + { send(userIds = listOf(1L, 99L)) }, + BulkSelectionRejected.UNKNOWN_USERS, + "userIds", + 99L, + ) + } + + @Test + fun `naming the same member twice is refused`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertRefusal( + { send(userIds = listOf(1L, 1L)) }, + BulkSelectionRejected.DUPLICATE_USERS, + "userIds", + 1L, + ) + } + + @Test + fun `ticking back in somebody who is not in the selection is refused`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertRefusal( + { send(forciblyIncluded = setOf(99L)) }, + BulkSelectionRejected.UNKNOWN_FORCED, + "forciblyIncludedUserIds", + 99L, + ) + } + + @Test + fun `ticking back in somebody the send still will not write to is refused`() { + plan( + row(1L, "Ann Transfer", ContributionEmailKind.REMINDER), + row(4L, "Dan Honorary", ContributionEmailKind.REMINDER, BulkRowDisposition.EXCLUDED, BulkRowReason.HONORARY), + ) + + assertRefusal( + { send(forciblyIncluded = setOf(4L)) }, + BulkSelectionRejected.NON_RECIPIENT_FORCED, + "forciblyIncludedUserIds", + 4L, + ) + } + } + + @Nested + inner class TheFeeThatIsStated { + + @Test + fun `an overridden fee type re-prices the record from the period`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + val written = slot() + every { reminders.create(capture(written)) } answers { written.captured } + + send(feeTypeOverrides = mapOf(1L to BulkFeeType.ALUMNI_FEE)) + + assertThat(written.captured.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) + assertThat(written.captured.amount).isEqualTo(10.0) + } + + @Test + fun `a fee type naming somebody the send skips refuses the whole thing`() { + plan( + row(1L, "Ann Transfer", ContributionEmailKind.REMINDER), + row(4L, "Dan Honorary", ContributionEmailKind.REMINDER, BulkRowDisposition.EXCLUDED, BulkRowReason.HONORARY), + ) + + assertThatThrownBy { send(feeTypeOverrides = mapOf(4L to BulkFeeType.ALUMNI_FEE)) } + .isInstanceOf(BulkSelectionRejected::class.java) + verify(exactly = 0) { reminders.create(any()) } + } + } + + @Nested + inner class TheDatesASendNeeds { + + @Test + fun `a date nobody needs may be left out`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertThat(send(debitDate = null).remindersSent).isEqualTo(1) + } + + @Test + fun `a payment request is refused without the date it promises`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertDateRefusal( + { send(paymentDueDate = null) }, + BulkFieldRejected.DATE_REQUIRED, + "paymentDueDate", + ) + } + + @Test + fun `a pre-notification is refused without the date it announces`() { + plan(row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION)) + + assertDateRefusal({ send(debitDate = null) }, BulkFieldRejected.DATE_REQUIRED, "debitDate") + } + + @Test + fun `switching a member onto the other email makes that email's date required`() { + plan(row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION)) + + assertDateRefusal( + { send(kindOverrides = mapOf(1L to ContributionEmailKind.REMINDER), paymentDueDate = null) }, + BulkFieldRejected.DATE_REQUIRED, + "paymentDueDate", + ) + } + } + + @Nested + inner class TheDatesAPeriodAllows { + + @Test + fun `a due date before the period starts is refused`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertDateRefusal( + { send(paymentDueDate = period.startDate.minusDays(1)) }, + BulkFieldRejected.DATE_OUTSIDE_PERIOD, + "paymentDueDate", + ) + } + + @Test + fun `a due date shortly after the period ends still chases the last unpaid members`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertThat(send(paymentDueDate = period.endDate.plusMonths(3)).remindersSent).isEqualTo(1) + } + + @Test + fun `a due date further than three months past the period is refused`() { + plan(row(1L, "Ann Transfer", ContributionEmailKind.REMINDER)) + + assertDateRefusal( + { send(paymentDueDate = period.endDate.plusMonths(3).plusDays(1)) }, + BulkFieldRejected.DATE_OUTSIDE_PERIOD, + "paymentDueDate", + ) + } + + @Test + fun `a debit date outside the period is refused against its own field`() { + plan(row(1L, "Ann Debit", ContributionEmailKind.INCASSO_NOTIFICATION)) + + assertDateRefusal( + { send(debitDate = period.endDate.plusYears(1)) }, + BulkFieldRejected.DATE_OUTSIDE_PERIOD, + "debitDate", + ) + } + } + + // ── Fixture ────────────────────────────────────────────────────────────── + + /** A refusal names its code, its field and the ids at fault, and writes nothing. */ + private fun assertRefusal(send: () -> Unit, code: String, field: String, vararg values: Long) { + assertThatThrownBy(send) + .isInstanceOf(BulkSelectionRejected::class.java) + .satisfies({ + val violation = (it as BulkSelectionRejected).violations.single { v -> v.code == code } + assertThat(violation.field).isEqualTo(field) + assertThat(violation.values).containsExactly(*values.toTypedArray()) + }) + verify(exactly = 0) { reminders.create(any()) } + verify(exactly = 0) { preNotifications.create(any()) } + } + + private fun assertDateRefusal(send: () -> Unit, code: String, field: String) { + assertThatThrownBy(send) + .isInstanceOf(BulkFieldRejected::class.java) + .satisfies({ + val violation = (it as BulkFieldRejected).violations.single() + assertThat(violation.code).isEqualTo(code) + assertThat(violation.field).isEqualTo(field) + }) + verify(exactly = 0) { reminders.create(any()) } + verify(exactly = 0) { preNotifications.create(any()) } + } + + private fun send( + userIds: List? = null, + forciblyIncluded: Set = emptySet(), + kindOverrides: Map = emptyMap(), + paymentDueDate: LocalDate? = dueDate, + debitDate: LocalDate? = this.debitDate, + feeTypeOverrides: Map = emptyMap(), + ) = useCases.send( + contributionPeriodId = periodId, + userIds = userIds ?: planned.map { it.userId }, + forciblyIncluded = forciblyIncluded, + kindOverrides = kindOverrides, + paymentDueDate = paymentDueDate, + debitDate = debitDate, + feeTypeOverrides = feeTypeOverrides, + ) + + private var planned: List = emptyList() + + private fun plan(vararg rows: ContributionEmailRow) { + planned = rows.toList() + // As the planner does: an id no row was drawn for is named on the plan, not lost. + every { planner.plan(periodId, any()) } answers { + val known = planned.map { it.userId }.toSet() + val unknown = secondArg>().distinct().filterNot { it in known }.sorted() + ContributionEmailPlan(periodId, planned, unknown) + } + every { periods.findById(periodId) } returns period + every { users.findById(any()) } answers { member(firstArg()) } + // `create` is generic, so a relaxed mock returns a stand-in of the erased type. + every { reminders.create(any()) } answers { firstArg() } + every { preNotifications.create(any()) } answers { firstArg() } + } + + private fun row( + userId: Long, + name: String, + defaultKind: ContributionEmailKind, + disposition: BulkRowDisposition = BulkRowDisposition.INCLUDED, + reason: BulkRowReason? = null, + ) = ContributionEmailRow( + userId = userId, + name = name, + memberType = MemberType.REGULAR, + memberSince = LocalDate.of(2025, 9, 1), + disposition = disposition, + reason = reason, + defaultKind = defaultKind, + feeType = if (reason == BulkRowReason.HONORARY) null else BulkFeeType.FULL_YEAR_FEE, + amount = if (reason == BulkRowReason.HONORARY) null else 45.0, + lastRemindedOn = null, + lastNotifiedOn = null, + ) + + private fun member(userId: Long) = User( + username = "member$userId", + email = "member$userId@example.com", + password = "dummy", + initials = "MM", + firstName = "Member", + lastName = "$userId", + phoneNumber = "0612345678", + discord = "member$userId#0001", + ).seeded(userId) +} diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionEmailMessageServiceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionEmailMessageServiceTest.kt new file mode 100644 index 000000000..31664c1ef --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionEmailMessageServiceTest.kt @@ -0,0 +1,185 @@ +package net.blueshell.api.contribution.domain + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import net.blueshell.api.contribution.api.ContributionPeriodService +import net.blueshell.api.contribution.persistence.ContributionPeriod +import net.blueshell.api.email.api.EmailPreviewRenderer +import net.blueshell.api.platform.config.BankProperties +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.dto.bulk.BulkRowDisposition +import net.blueshell.api.shared.dto.bulk.BulkRowReason +import net.blueshell.api.shared.email.EmailContent +import net.blueshell.api.shared.enums.MemberType +import net.blueshell.api.shared.model.RenderedEmailPreview +import net.blueshell.api.user.api.UserService +import net.blueshell.api.user.persistence.User +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.springframework.web.server.ResponseStatusException +import java.time.LocalDate + +/** Reading one member's payment email, built by the same builders the send uses. */ +class ContributionEmailMessageServiceTest { + + private val periodId = 7L + private val date = LocalDate.of(2026, 3, 1) + + private val period = ContributionPeriod( + startDate = LocalDate.of(2025, 9, 1), + endDate = LocalDate.of(2026, 8, 31), + halfYearCutoffDate = LocalDate.of(2026, 2, 1), + halfYearFee = 25.0, + fullYearFee = 45.0, + alumniFee = 10.0, + ).seeded(periodId) + + private val planner: ContributionEmailPlanner = mockk() + private val periods: ContributionPeriodService = mockk() + private val users: UserService = mockk() + private val renderer: EmailPreviewRenderer = mockk() + + private val service = ContributionEmailMessageService(planner, periods, users, renderer, BankProperties()) + + private val alice = User( + username = "alice", + email = "alice@example.com", + password = "hash", + initials = "AR", + firstName = "Alice", + lastName = "Regular", + ).seeded(1L) + + @Test + fun `a payment request quotes the amount and asks for a transfer by the date`() { + givenSelected(ContributionEmailKind.REMINDER, BulkFeeType.HALF_YEAR_FEE) + val captured = capturingRenderer() + + val message = service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, null) + + assertThat(message.kind).isEqualTo(ContributionEmailKind.REMINDER) + assertThat(message.feeType).isEqualTo(BulkFeeType.HALF_YEAR_FEE) + assertThat(captured.captured.markdownContent).contains("€25,00", "1 March 2026") + assertThat(message.recipientEmail).isEqualTo("alice@example.com") + } + + @Test + fun `a pre-notification announces the debit and asks for no transfer`() { + givenSelected(ContributionEmailKind.INCASSO_NOTIFICATION, BulkFeeType.FULL_YEAR_FEE) + val captured = capturingRenderer() + + val message = service.render(ContributionEmailKind.INCASSO_NOTIFICATION, periodId, 1L, date, null) + + assertThat(message.kind).isEqualTo(ContributionEmailKind.INCASSO_NOTIFICATION) + assertThat(captured.captured.markdownContent) + .contains("collected", "€45,00") + .doesNotContain("Bank transfer") + } + + @Test + fun `the kind asked for is the kind rendered, whatever the member's flag says`() { + givenSelected(ContributionEmailKind.INCASSO_NOTIFICATION, BulkFeeType.FULL_YEAR_FEE) + val captured = capturingRenderer() + + service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, null) + + assertThat(captured.captured.markdownContent).contains("Bank transfer") + } + + @Test + fun `an overridden fee type is what the email quotes, with its own reason`() { + givenSelected(ContributionEmailKind.REMINDER, BulkFeeType.FULL_YEAR_FEE) + val captured = capturingRenderer() + + val message = service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, BulkFeeType.ALUMNI_FEE) + + assertThat(message.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) + assertThat(captured.captured.markdownContent).contains("€10,00", "alumni member") + } + + @Test + fun `a warned member's email can still be read`() { + givenSelected( + ContributionEmailKind.REMINDER, + BulkFeeType.FULL_YEAR_FEE, + disposition = BulkRowDisposition.WARNING, + reason = BulkRowReason.ALREADY_PAID, + ) + capturingRenderer() + + assertThat(service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, null).subject).isNotBlank() + } + + @Test + fun `a hard-excluded member has no email to read`() { + givenSelected( + ContributionEmailKind.REMINDER, + feeType = null, + disposition = BulkRowDisposition.EXCLUDED, + reason = BulkRowReason.HONORARY, + ) + + assertThatThrownBy { service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, null) } + .isInstanceOf(ResponseStatusException::class.java) + .hasMessageContaining("writes nothing") + } + + @Test + fun `a member the plan does not know is not found`() { + every { planner.plan(periodId, listOf(1L)) } returns ContributionEmailPlan(periodId, emptyList()) + + assertThatThrownBy { service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, null) } + .isInstanceOf(ResponseStatusException::class.java) + } + + @Test + fun `reading an email goes through the shared renderer and writes nothing`() { + givenSelected(ContributionEmailKind.REMINDER, BulkFeeType.FULL_YEAR_FEE) + capturingRenderer() + + service.render(ContributionEmailKind.REMINDER, periodId, 1L, date, null) + + verify(exactly = 1) { renderer.render(any()) } + } + + // ── Fixture ────────────────────────────────────────────────────────────── + + private fun capturingRenderer(): io.mockk.CapturingSlot { + val captured = slot() + every { renderer.render(capture(captured)) } answers { + RenderedEmailPreview(captured.captured.subject, "${captured.captured.markdownContent}") + } + return captured + } + + private fun givenSelected( + defaultKind: ContributionEmailKind, + feeType: BulkFeeType?, + disposition: BulkRowDisposition = BulkRowDisposition.INCLUDED, + reason: BulkRowReason? = null, + ) { + every { planner.plan(periodId, listOf(1L)) } returns ContributionEmailPlan( + periodId, + listOf( + ContributionEmailRow( + userId = 1L, + name = alice.fullName, + memberType = MemberType.REGULAR, + memberSince = LocalDate.of(2025, 9, 1), + disposition = disposition, + reason = reason, + defaultKind = defaultKind, + feeType = feeType, + amount = feeType?.let { resolveFeeAmount(it, period) }, + lastRemindedOn = null, + lastNotifiedOn = null, + ), + ), + ) + every { periods.findById(periodId) } returns period + every { users.findById(1L) } returns alice + } +} diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionEmailPlannerTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionEmailPlannerTest.kt new file mode 100644 index 000000000..7da907c6c --- /dev/null +++ b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionEmailPlannerTest.kt @@ -0,0 +1,335 @@ +package net.blueshell.api.contribution.domain + +import io.mockk.every +import io.mockk.mockk +import net.blueshell.api.contribution.api.ContributionPeriodService +import net.blueshell.api.contribution.api.ContributionService +import net.blueshell.api.contribution.persistence.Contribution +import net.blueshell.api.contribution.persistence.ContributionPeriod +import net.blueshell.api.contribution.persistence.ContributionReminder +import net.blueshell.api.contribution.persistence.IncassoNotification +import net.blueshell.api.shared.dto.bulk.BulkFeeType +import net.blueshell.api.shared.dto.bulk.BulkRowDisposition +import net.blueshell.api.shared.dto.bulk.BulkRowReason +import net.blueshell.api.shared.enums.MemberType +import net.blueshell.api.user.api.MembershipService +import net.blueshell.api.user.api.UserErasureService +import net.blueshell.api.user.api.UserService +import net.blueshell.api.user.persistence.Membership +import net.blueshell.api.user.persistence.User +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.ZoneOffset + +/** What the payment emails would do to a selection. */ +class ContributionEmailPlannerTest { + + private val periodId = 7L + + private val period = ContributionPeriod( + startDate = LocalDate.of(2025, 9, 1), + endDate = LocalDate.of(2026, 8, 31), + halfYearCutoffDate = LocalDate.of(2026, 2, 1), + halfYearFee = 25.0, + fullYearFee = 45.0, + alumniFee = 10.0, + ).seeded(periodId) + + private val periods: ContributionPeriodService = mockk() + private val contributions: ContributionService = mockk() + private val memberships: MembershipService = mockk() + private val users: UserService = mockk() + private val reminders: ContributionReminderService = mockk() + private val preNotifications: IncassoNotificationService = mockk() + private val erasure: UserErasureService = mockk() + + private val planner = ContributionEmailPlanner( + periods, contributions, memberships, users, reminders, preNotifications, erasure, + ) + + @Nested + inner class WhichEmailAMemberGets { + + @Test + fun `the direct-debit flag chooses it, not the operator`() { + given(membership(1L, "Ann Debit", incasso = true), membership(2L, "Ben Transfer", incasso = false)) + + val plan = planner.plan(periodId, listOf(1L, 2L)) + + assertThat(plan.byUserId(1L)!!.defaultKind).isEqualTo(ContributionEmailKind.INCASSO_NOTIFICATION) + assertThat(plan.byUserId(2L)!!.defaultKind).isEqualTo(ContributionEmailKind.REMINDER) + } + + @Test + fun `the flag is read off the membership still running, not the one that ended`() { + val member = member(1L, "Ann Moved") + given( + Membership(user = member, startDate = LocalDate.of(2024, 9, 1), endDate = LocalDate.of(2025, 8, 31), incasso = true), + Membership(user = member, startDate = LocalDate.of(2025, 9, 1), incasso = false), + ) + + assertThat(planner.plan(periodId, listOf(1L)).byUserId(1L)!!.defaultKind) + .isEqualTo(ContributionEmailKind.REMINDER) + } + + @Test + fun `a member holding no membership at all is asked to transfer`() { + givenNoMemberships(member(9L, "Kim Ada")) + + assertThat(planner.plan(periodId, listOf(9L)).byUserId(9L)!!.defaultKind) + .isEqualTo(ContributionEmailKind.REMINDER) + } + } + + @Nested + inner class WhoIsWrittenTo { + + @Test + fun `the selection is the population, so nobody is added to it`() { + given(membership(1L, "Ann One", incasso = false), membership(2L, "Ben Two", incasso = false)) + + assertThat(planner.plan(periodId, listOf(1L)).rows.map { it.name }).containsExactly("Ann One") + } + + @Test + fun `an honorary member is listed, hard-excluded and owes nothing`() { + given(membership(1L, "Ann Honorary", incasso = false, memberType = MemberType.HONORARY)) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.disposition).isEqualTo(BulkRowDisposition.EXCLUDED) + assertThat(row.reason).isEqualTo(BulkRowReason.HONORARY) + assertThat(row.feeType).isNull() + assertThat(row.amount).isNull() + assertThat(row.isHardExcluded).isTrue() + } + + @Test + fun `a deleted account is hard-excluded`() { + given(membership(1L, "Ann Gone", incasso = false), deleted = true) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.disposition).isEqualTo(BulkRowDisposition.EXCLUDED) + assertThat(row.reason).isEqualTo(BulkRowReason.DELETED) + } + + @Test + fun `a member with no address on file is hard-excluded`() { + given(membership(1L, "Ann Unreachable", incasso = false, email = "")) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.disposition).isEqualTo(BulkRowDisposition.EXCLUDED) + assertThat(row.reason).isEqualTo(BulkRowReason.NO_EMAIL) + } + + @Test + fun `a hard exclusion is not overridable`() { + given(membership(1L, "Ann Honorary", incasso = false, memberType = MemberType.HONORARY)) + + assertThat(planner.plan(periodId, listOf(1L)).recipients(setOf(1L))).isEmpty() + } + + @Test + fun `a member who has paid is warned about rather than dropped`() { + given(membership(1L, "Ann Paid", incasso = false), paid = setOf(1L)) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.disposition).isEqualTo(BulkRowDisposition.WARNING) + assertThat(row.reason).isEqualTo(BulkRowReason.ALREADY_PAID) + } + + @Test + fun `a member who held no membership during the period is warned about`() { + given( + membership( + 1L, + "Kim Ada", + incasso = false, + startDate = LocalDate.of(2023, 9, 1), + endDate = LocalDate.of(2024, 8, 31), + ), + ) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.disposition).isEqualTo(BulkRowDisposition.WARNING) + assertThat(row.reason).isEqualTo(BulkRowReason.NOT_MEMBER_IN_PERIOD) + } + + @Test + fun `a warned member is written to once ticked back in`() { + given(membership(1L, "Ann Paid", incasso = false), paid = setOf(1L)) + + val plan = planner.plan(periodId, listOf(1L)) + + assertThat(plan.recipients(emptySet())).isEmpty() + assertThat(plan.recipients(setOf(1L)).map { it.name }).containsExactly("Ann Paid") + } + + @Test + fun `an id naming nobody is named on the plan rather than dropped`() { + given(membership(1L, "Ann One", incasso = false)) + every { users.existsById(99L) } returns false + + val plan = planner.plan(periodId, listOf(1L, 99L)) + + assertThat(plan.rows.map { it.userId }).containsExactly(1L) + assertThat(plan.unknownUserIds).containsExactly(99L) + } + + @Test + fun `a selection that resolves whole names nobody as unknown`() { + given(membership(1L, "Ann One", incasso = false)) + + assertThat(planner.plan(periodId, listOf(1L)).unknownUserIds).isEmpty() + } + } + + @Nested + inner class WhatEachMemberOwes { + + @Test + fun `a membership started before the cutoff pays the full year`() { + given(membership(1L, "Ann Early", incasso = false, startDate = LocalDate.of(2025, 9, 1))) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.feeType).isEqualTo(BulkFeeType.FULL_YEAR_FEE) + assertThat(row.amount).isEqualTo(45.0) + } + + @Test + fun `a membership started after the cutoff pays the half year`() { + given(membership(1L, "Ben Late", incasso = false, startDate = LocalDate.of(2026, 3, 1))) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.feeType).isEqualTo(BulkFeeType.HALF_YEAR_FEE) + assertThat(row.amount).isEqualTo(25.0) + } + + @Test + fun `an alumni member pays the alumni fee`() { + given(membership(1L, "Cara Alumni", incasso = false, memberType = MemberType.ALUMNI)) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) + assertThat(row.amount).isEqualTo(10.0) + } + } + + @Nested + inner class WhenTheyWereLastWrittenTo { + + @Test + fun `each statement carries its own date`() { + val ann = member(1L, "Ann Moved") + given( + Membership(user = ann, startDate = LocalDate.of(2025, 9, 1), incasso = true), + sentReminders = listOf(reminderFor(ann, LocalDate.of(2025, 9, 12))), + ) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.lastRemindedOn).isEqualTo(LocalDate.of(2025, 9, 12)) + assertThat(row.lastNotifiedOn).isNull() + } + + @Test + fun `the most recent of several sends is the one reported`() { + val ann = member(1L, "Ann Chased") + given( + Membership(user = ann, startDate = LocalDate.of(2025, 9, 1), incasso = false), + sentReminders = listOf( + reminderFor(ann, LocalDate.of(2025, 9, 12)), + reminderFor(ann, LocalDate.of(2026, 2, 3)), + ), + ) + + assertThat(planner.plan(periodId, listOf(1L)).rows.single().lastRemindedOn) + .isEqualTo(LocalDate.of(2026, 2, 3)) + } + + @Test + fun `a member never written to shows no date on either`() { + given(membership(1L, "Ann New", incasso = false)) + + val row = planner.plan(periodId, listOf(1L)).rows.single() + + assertThat(row.lastRemindedOn).isNull() + assertThat(row.lastNotifiedOn).isNull() + } + } + + // ── Fixture ────────────────────────────────────────────────────────────── + + private fun given( + vararg held: Membership, + paid: Set = emptySet(), + deleted: Boolean = false, + sentReminders: List = emptyList(), + sentPreNotifications: List = emptyList(), + ) { + every { periods.findById(periodId) } returns period + every { contributions.findByContributionPeriodId(periodId) } returns + paid.map { + Contribution( + id = Contribution.Id(it, periodId), + user = member(it, "Paid $it"), + contributionPeriod = period, + ) + }.toMutableList() + every { memberships.findByUserIdsWithMembers(any()) } returns held.toList().groupBy { it.userId } + every { reminders.findByContributionPeriodId(periodId) } returns sentReminders.toMutableList() + every { preNotifications.findByContributionPeriodId(periodId) } returns sentPreNotifications.toMutableList() + every { erasure.isDeleted(any()) } returns deleted + } + + private fun givenNoMemberships(member: User) { + given() + every { users.existsById(member.id!!) } returns true + every { users.findById(member.id!!) } returns member + } + + private fun membership( + userId: Long, + fullName: String, + incasso: Boolean, + memberType: MemberType = MemberType.REGULAR, + startDate: LocalDate = LocalDate.of(2025, 9, 1), + endDate: LocalDate? = null, + email: String = "member$userId@example.com", + ) = Membership( + user = member(userId, fullName, email), + startDate = startDate, + endDate = endDate, + memberType = memberType, + incasso = incasso, + ) + + private fun member(userId: Long, fullName: String, email: String = "member$userId@example.com"): User { + val (firstName, lastName) = fullName.split(" ") + return User( + username = "member$userId", + email = email, + password = "dummy", + initials = "${firstName.first()}${lastName.first()}", + firstName = firstName, + lastName = lastName, + phoneNumber = "0612345678", + discord = "member$userId#0001", + ).seeded(userId) + } + + private fun reminderFor(member: User, on: LocalDate) = ContributionReminder( + user = member, + contributionPeriod = period, + askedAt = on.atStartOfDay().toInstant(ZoneOffset.UTC), + ).seeded(member.id!!) +} diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilderTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilderTest.kt index 43a82c88f..5ef950ed5 100644 --- a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilderTest.kt +++ b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/ContributionReminderEmailBuilderTest.kt @@ -17,7 +17,7 @@ import java.util.Locale /** * The two payment requests. * - * The fee-cycle one quotes one amount and the reason it applies; the single-member one + * The bulk one quotes one amount and the reason it applies; the single-member one * quotes the period's options because no fee type was chosen for it. */ class ContributionReminderEmailBuilderTest { @@ -39,7 +39,7 @@ class ContributionReminderEmailBuilderTest { } @Nested - inner class TheFeeCyclePaymentRequest { + inner class TheBulkPaymentRequest { @Test fun `states the amount, the reason for it and the date it is due`() { @@ -122,7 +122,7 @@ class ContributionReminderEmailBuilderTest { } /** - * Untouched by the fee cycle. Kept here because the fee-cycle change moved the other + * Unchanged by the bulk send. Kept here because that change moved the other * overload out from under it, and a rename is the easiest way to break a caller. */ @Nested diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCycleEmailPreviewServiceTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCycleEmailPreviewServiceTest.kt deleted file mode 100644 index b0e78a004..000000000 --- a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCycleEmailPreviewServiceTest.kt +++ /dev/null @@ -1,207 +0,0 @@ -package net.blueshell.api.contribution.domain - -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import net.blueshell.api.contribution.api.ContributionPeriodService -import net.blueshell.api.contribution.persistence.ContributionPeriod -import net.blueshell.api.email.api.EmailPreviewRenderer -import net.blueshell.api.platform.config.BankProperties -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.BulkRowDisposition -import net.blueshell.api.shared.dto.bulk.BulkRowReason -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.email.EmailContent -import net.blueshell.api.shared.enums.MemberType -import net.blueshell.api.shared.model.RenderedEmailPreview -import net.blueshell.api.user.api.UserService -import net.blueshell.api.user.persistence.User -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Test -import org.springframework.web.server.ResponseStatusException -import java.time.LocalDate - -/** - * Reading one member's fee-cycle email. - * - * Which statement comes back is the member's own side of the partition, and it is built by - * the same builders the send uses — which is what stops the preview and the email drifting. - * A preview writes nothing, and these assert that absence. - */ -class FeeCycleEmailPreviewServiceTest { - - private val periodId = 7L - private val dueDate = LocalDate.of(2026, 3, 1) - private val debitDate = LocalDate.of(2026, 3, 15) - private val dates = FeeCycleDates(paymentDue = dueDate, debit = debitDate) - - private val period = ContributionPeriod( - startDate = LocalDate.of(2025, 9, 1), - endDate = LocalDate.of(2026, 8, 31), - halfYearCutoffDate = LocalDate.of(2026, 2, 1), - halfYearFee = 25.0, - fullYearFee = 45.0, - alumniFee = 10.0, - ).seeded(periodId) - - private val planner: FeeCyclePlanner = mockk() - private val periods: ContributionPeriodService = mockk() - private val users: UserService = mockk() - private val renderer: EmailPreviewRenderer = mockk() - - private val service = FeeCycleEmailPreviewService(planner, periods, users, renderer, BankProperties()) - - private val alice = User( - username = "alice", - email = "alice@example.com", - password = "hash", - initials = "AR", - firstName = "Alice", - lastName = "Regular", - ).seeded(1L) - - /** Echo the markdown back as the body, so the assertions read what was handed over. */ - private fun capturingRenderer(): io.mockk.CapturingSlot { - val captured = slot() - every { renderer.render(capture(captured)) } answers { - RenderedEmailPreview(captured.captured.subject, "${captured.captured.markdownContent}") - } - return captured - } - - @Test - fun `a transfer member reads the payment request`() { - givenInCycle(FeeCycleGroup.TRANSFER, BulkFeeType.HALF_YEAR_FEE) - capturingRenderer() - - val preview = service.preview(periodId, 1L, dates, null) - - assertThat(preview.group).isEqualTo(FeeCycleGroup.TRANSFER) - assertThat(preview.subject).isEqualTo("Please pay your Blueshell contribution (2025/2026)") - assertThat(preview.html) - .contains("Amount due: €25,00") - .contains("the half-year fee") - .contains("1 March 2026") - assertThat(preview.recipientEmail).isEqualTo("alice@example.com") - assertThat(preview.recipientName).isEqualTo("Alice Regular") - } - - @Test - fun `a direct-debit member reads the pre-notification`() { - givenInCycle(FeeCycleGroup.DIRECT_DEBIT, BulkFeeType.FULL_YEAR_FEE) - capturingRenderer() - - val preview = service.preview(periodId, 1L, dates, null) - - assertThat(preview.group).isEqualTo(FeeCycleGroup.DIRECT_DEBIT) - assertThat(preview.subject) - .isEqualTo("Your Blueshell contribution will be collected automatically (2025/2026)") - assertThat(preview.html) - .contains("Amount to be collected: €45,00") - .contains("the full-year fee") - .contains("15 March 2026") - } - - // The read has to show what the send would do, overrides included. - @Test - fun `an overridden fee type is what the preview quotes`() { - givenInCycle(FeeCycleGroup.TRANSFER, BulkFeeType.FULL_YEAR_FEE) - capturingRenderer() - - val preview = service.preview(periodId, 1L, dates, BulkFeeType.ALUMNI_FEE) - - assertThat(preview.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) - assertThat(preview.html).contains("Amount due: €10,00").contains("the alumni fee") - } - - /** - * The email is rendered by the shared renderer from an `EmailContent`, and nowhere else. - * A second rendering path is how a preview starts disagreeing with what goes out. - */ - @Test - fun `renders through the shared renderer, from the content the send would build`() { - givenInCycle(FeeCycleGroup.TRANSFER, BulkFeeType.FULL_YEAR_FEE) - val handedOver = capturingRenderer() - - val preview = service.preview(periodId, 1L, dates, null) - - verify(exactly = 1) { renderer.render(any()) } - assertThat(handedOver.captured.recipientEmail).isEqualTo("alice@example.com") - assertThat(preview.html).isEqualTo("${handedOver.captured.markdownContent}") - } - - @Test - fun `refuses a member the cycle is not about`() { - every { planner.plan(periodId) } returns FeeCyclePlan(periodId, emptyList()) - - assertThatThrownBy { service.preview(periodId, 99L, dates, null) } - .isInstanceOf(ResponseStatusException::class.java) - .hasMessageContaining("not in this period's fee cycle") - } - - /** - * Refused in the api rather than hidden by the dialog: an excluded member would otherwise - * render an honorary member's statement, or one addressed to nobody. - */ - @Test - fun `refuses a member with no email address`() { - every { planner.plan(periodId) } returns FeeCyclePlan( - periodId, - listOf( - participant( - disposition = BulkRowDisposition.EXCLUDED, - reason = BulkRowReason.NO_EMAIL, - feeType = BulkFeeType.FULL_YEAR_FEE, - ), - ), - ) - - assertThatThrownBy { service.preview(periodId, 1L, dates, null) } - .isInstanceOf(ResponseStatusException::class.java) - .hasMessageContaining("sends nothing to that member") - } - - @Test - fun `refuses an honorary member, who owes nothing`() { - every { planner.plan(periodId) } returns FeeCyclePlan( - periodId, - listOf( - participant( - disposition = BulkRowDisposition.EXCLUDED, - reason = BulkRowReason.HONORARY, - feeType = null, - ), - ), - ) - - assertThatThrownBy { service.preview(periodId, 1L, dates, null) } - .isInstanceOf(ResponseStatusException::class.java) - .hasMessageContaining("sends nothing to that member") - } - - private fun givenInCycle(group: FeeCycleGroup, feeType: BulkFeeType) { - every { planner.plan(periodId) } returns FeeCyclePlan(periodId, listOf(participant(group, feeType = feeType))) - every { users.findById(1L) } returns alice - every { periods.findById(periodId) } returns period - } - - private fun participant( - group: FeeCycleGroup = FeeCycleGroup.TRANSFER, - disposition: BulkRowDisposition = BulkRowDisposition.INCLUDED, - reason: BulkRowReason? = null, - feeType: BulkFeeType? = BulkFeeType.FULL_YEAR_FEE, - ) = FeeCycleParticipant( - userId = 1L, - name = "Alice Regular", - memberType = if (reason == BulkRowReason.HONORARY) MemberType.HONORARY else MemberType.REGULAR, - memberSince = LocalDate.of(2025, 9, 1), - group = group, - disposition = disposition, - reason = reason, - feeType = feeType, - amount = feeType?.let { resolveFeeAmount(it, period) }, - lastAskedOn = null, - ) -} diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCyclePlannerTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCyclePlannerTest.kt deleted file mode 100644 index 1f03a59bb..000000000 --- a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCyclePlannerTest.kt +++ /dev/null @@ -1,321 +0,0 @@ -package net.blueshell.api.contribution.domain - -import io.mockk.every -import io.mockk.mockk -import net.blueshell.api.contribution.api.ContributionPeriodService -import net.blueshell.api.contribution.api.ContributionService -import net.blueshell.api.contribution.persistence.Contribution -import net.blueshell.api.contribution.persistence.ContributionPeriod -import net.blueshell.api.contribution.persistence.ContributionReminder -import net.blueshell.api.contribution.persistence.IncassoNotification -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.BulkRowDisposition -import net.blueshell.api.shared.dto.bulk.BulkRowReason -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.enums.MemberType -import net.blueshell.api.user.api.MembershipService -import net.blueshell.api.user.api.UserErasureService -import net.blueshell.api.user.persistence.Membership -import net.blueshell.api.user.persistence.User -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import java.time.LocalDate -import java.time.ZoneOffset - -/** - * Who a period's fee cycle is about, and what each of them owes. - * - * The population is every member of the period who has not paid, so a paid member is - * absent rather than skipped, and the side of the partition is the member's direct-debit - * flag rather than anything the operator chose. - */ -class FeeCyclePlannerTest { - - private val periodId = 7L - private val cutoff = LocalDate.of(2026, 2, 1) - - private val period = ContributionPeriod( - startDate = LocalDate.of(2025, 9, 1), - endDate = LocalDate.of(2026, 8, 31), - halfYearCutoffDate = cutoff, - halfYearFee = 25.0, - fullYearFee = 45.0, - alumniFee = 10.0, - ).seeded(periodId) - - private val periods: ContributionPeriodService = mockk() - private val contributions: ContributionService = mockk() - private val memberships: MembershipService = mockk() - private val reminders: ContributionReminderService = mockk() - private val preNotifications: IncassoNotificationService = mockk() - private val erasure: UserErasureService = mockk() - - private val planner = - FeeCyclePlanner(periods, contributions, memberships, reminders, preNotifications, erasure) - - @Nested - inner class ThePartition { - - @Test - fun `the direct-debit flag decides the side, not the operator`() { - given( - membership(1L, "Ann Debit", incasso = true), - membership(2L, "Ben Transfer", incasso = false), - ) - - val plan = planner.plan(periodId) - - assertThat(plan.group(FeeCycleGroup.DIRECT_DEBIT).map { it.name }).containsExactly("Ann Debit") - assertThat(plan.group(FeeCycleGroup.TRANSFER).map { it.name }).containsExactly("Ben Transfer") - } - - @Test - fun `both sides are recipients, so one confirmation reaches everybody`() { - given( - membership(1L, "Ann Debit", incasso = true), - membership(2L, "Ben Transfer", incasso = false), - ) - - assertThat(planner.plan(periodId).recipients.map { it.name }) - .containsExactly("Ann Debit", "Ben Transfer") - } - } - - @Nested - inner class WhoIsInTheCycle { - - @Test - fun `a member who has paid is absent rather than listed as skipped`() { - given( - membership(1L, "Ann Paid", incasso = false), - membership(2L, "Ben Unpaid", incasso = false), - paid = setOf(1L), - ) - - assertThat(planner.plan(periodId).participants.map { it.name }).containsExactly("Ben Unpaid") - } - - // Their absence from the send is visible rather than silent. - @Test - fun `an honorary member is listed, excluded and owes nothing`() { - given(membership(1L, "Ann Honorary", incasso = false, memberType = MemberType.HONORARY)) - - val row = planner.plan(periodId).participants.single() - - assertThat(row.disposition).isEqualTo(BulkRowDisposition.EXCLUDED) - assertThat(row.reason).isEqualTo(BulkRowReason.HONORARY) - assertThat(row.feeType).isNull() - assertThat(row.amount).isNull() - assertThat(row.willSend).isFalse() - } - - /** - * Deletion anonymises the address to a placeholder that would pass an is-it-blank - * test, and it does not end the memberships — so without this the cycle would write - * to a deleted account. - */ - @Test - fun `a deleted account is listed, excluded and never written to`() { - given(membership(1L, "Ann Deleted", incasso = false), deleted = true) - - val row = planner.plan(periodId).participants.single() - - assertThat(row.disposition).isEqualTo(BulkRowDisposition.EXCLUDED) - assertThat(row.reason).isEqualTo(BulkRowReason.DELETED) - assertThat(row.willSend).isFalse() - } - - @Test - fun `a member with no email address is listed and excluded`() { - given(membership(1L, "Ann NoEmail", incasso = false, email = "")) - - val row = planner.plan(periodId).participants.single() - - assertThat(row.disposition).isEqualTo(BulkRowDisposition.EXCLUDED) - assertThat(row.reason).isEqualTo(BulkRowReason.NO_EMAIL) - assertThat(row.willSend).isFalse() - } - } - - @Nested - inner class WhatEachOneOwes { - - @Test - fun `the amount follows from the fee type and the period`() { - given( - membership(1L, "Ann Full", incasso = false, startDate = cutoff), - membership(2L, "Ben Half", incasso = false, startDate = cutoff.plusDays(1)), - membership(3L, "Cara Alumni", incasso = false, memberType = MemberType.ALUMNI), - ) - - val rows = planner.plan(periodId).participants.associateBy { it.name } - - assertThat(rows.getValue("Ann Full").feeType).isEqualTo(BulkFeeType.FULL_YEAR_FEE) - assertThat(rows.getValue("Ann Full").amount).isEqualTo(45.0) - assertThat(rows.getValue("Ben Half").feeType).isEqualTo(BulkFeeType.HALF_YEAR_FEE) - assertThat(rows.getValue("Ben Half").amount).isEqualTo(25.0) - assertThat(rows.getValue("Cara Alumni").feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) - assertThat(rows.getValue("Cara Alumni").amount).isEqualTo(10.0) - } - - /** - * A flag on a membership that has ended is not how the member pays now, and sending - * the wrong statement on a stale flag costs them money. - */ - @Test - fun `the active membership decides the side, not an older one that ended`() { - val member = member(1L, "Ann Switched") - given( - Membership( - user = member, - startDate = LocalDate.of(2025, 9, 1), - endDate = LocalDate.of(2026, 1, 31), - incasso = true, - ), - Membership(user = member, startDate = LocalDate.of(2026, 2, 1), incasso = false), - ) - - assertThat(planner.plan(periodId).participants.single().group) - .isEqualTo(FeeCycleGroup.TRANSFER) - } - - @Test - fun `an ended membership still decides when none is active, as for a closed period`() { - val member = member(1L, "Ann Left") - given( - Membership( - user = member, - startDate = LocalDate.of(2025, 9, 1), - endDate = LocalDate.of(2026, 1, 31), - incasso = true, - ), - ) - - assertThat(planner.plan(periodId).participants.single().group) - .isEqualTo(FeeCycleGroup.DIRECT_DEBIT) - } - - // Pricing a past period by a membership that did not exist during it would be wrong, - // so where several are active the newest of them is judged. - @Test - fun `the newest overlapping membership is the one judged`() { - val member = member(1L, "Ann Rejoined") - given( - Membership(user = member, startDate = LocalDate.of(2025, 9, 1), incasso = false), - Membership(user = member, startDate = cutoff.plusDays(1), incasso = false), - ) - - val row = planner.plan(periodId).participants.single() - - assertThat(row.memberSince).isEqualTo(cutoff.plusDays(1)) - assertThat(row.feeType).isEqualTo(BulkFeeType.HALF_YEAR_FEE) - } - } - - @Nested - inner class WhenTheyWereLastAsked { - - @Test - fun `a payment request is read for the transfer side and a pre-notification for the other`() { - val debitMember = member(1L, "Ann Debit") - val transferMember = member(2L, "Ben Transfer") - given( - Membership(user = debitMember, startDate = LocalDate.of(2025, 9, 1), incasso = true), - Membership(user = transferMember, startDate = LocalDate.of(2025, 9, 1), incasso = false), - sentReminders = listOf(reminderFor(transferMember, LocalDate.of(2026, 3, 4))), - sentPreNotifications = listOf(preNotificationFor(debitMember, LocalDate.of(2026, 3, 5))), - ) - - val rows = planner.plan(periodId).participants.associateBy { it.name } - - assertThat(rows.getValue("Ben Transfer").lastAskedOn).isEqualTo(LocalDate.of(2026, 3, 4)) - assertThat(rows.getValue("Ann Debit").lastAskedOn).isEqualTo(LocalDate.of(2026, 3, 5)) - } - - /** - * A member moved onto direct debit part way through a period has been asked by - * transfer and not yet pre-notified. Pooling the two records would claim they had - * been, and hide the send about to happen. - */ - @Test - fun `a request on the other side does not count as having been asked`() { - val moved = member(1L, "Ann Moved") - given( - Membership(user = moved, startDate = LocalDate.of(2025, 9, 1), incasso = true), - sentReminders = listOf(reminderFor(moved, LocalDate.of(2026, 3, 4))), - ) - - assertThat(planner.plan(periodId).participants.single().lastAskedOn).isNull() - } - - @Test - fun `a member never asked shows no date`() { - given(membership(1L, "Ann New", incasso = false)) - - assertThat(planner.plan(periodId).participants.single().lastAskedOn).isNull() - } - } - - // ── Fixture ────────────────────────────────────────────────────────────── - - private fun given( - vararg held: Membership, - paid: Set = emptySet(), - deleted: Boolean = false, - sentReminders: List = emptyList(), - sentPreNotifications: List = emptyList(), - ) { - every { periods.findById(periodId) } returns period - every { contributions.findByContributionPeriodId(periodId) } returns - paid.map { Contribution(id = Contribution.Id(it, periodId), user = member(it, "Paid $it"), contributionPeriod = period) } - .toMutableList() - every { memberships.findOverlappingWithMembers(period.startDate, period.endDate) } returns held.toList() - every { reminders.findByContributionPeriodId(periodId) } returns sentReminders.toMutableList() - every { erasure.isDeleted(any()) } returns deleted - every { preNotifications.findByContributionPeriodId(periodId) } returns sentPreNotifications.toMutableList() - } - - private fun membership( - userId: Long, - fullName: String, - incasso: Boolean, - memberType: MemberType = MemberType.REGULAR, - startDate: LocalDate = LocalDate.of(2025, 9, 1), - email: String = "member$userId@example.com", - ) = Membership( - user = member(userId, fullName, email), - startDate = startDate, - memberType = memberType, - incasso = incasso, - ) - - private fun member(userId: Long, fullName: String, email: String = "member$userId@example.com"): User { - val (firstName, lastName) = fullName.split(" ") - return User( - username = "member$userId", - email = email, - password = "dummy", - initials = "${firstName.first()}${lastName.first()}", - firstName = firstName, - lastName = lastName, - phoneNumber = "0612345678", - discord = "member$userId#0001", - ).seeded(userId) - } - - private fun reminderFor(member: User, on: LocalDate) = ContributionReminder( - user = member, - contributionPeriod = period, - askedAt = on.atStartOfDay().toInstant(ZoneOffset.UTC), - ).seeded(member.id!!) - - private fun preNotificationFor(member: User, on: LocalDate) = IncassoNotification( - user = member, - contributionPeriod = period, - feeType = BulkFeeType.FULL_YEAR_FEE, - amount = 45.0, - debitDate = on, - askedAt = on.atStartOfDay().toInstant(ZoneOffset.UTC), - ).seeded(member.id!!) -} diff --git a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCycleUseCasesTest.kt b/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCycleUseCasesTest.kt deleted file mode 100644 index f11eb2673..000000000 --- a/services/api/src/test/kotlin/net/blueshell/api/contribution/domain/FeeCycleUseCasesTest.kt +++ /dev/null @@ -1,244 +0,0 @@ -package net.blueshell.api.contribution.domain - -import io.mockk.every -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import net.blueshell.api.contribution.api.ContributionPeriodService -import net.blueshell.api.contribution.persistence.ContributionPeriod -import net.blueshell.api.contribution.persistence.ContributionReminder -import net.blueshell.api.contribution.persistence.IncassoNotification -import net.blueshell.api.shared.dto.bulk.BulkFeeType -import net.blueshell.api.shared.dto.bulk.BulkRowDisposition -import net.blueshell.api.shared.dto.bulk.BulkRowReason -import net.blueshell.api.shared.dto.bulk.BulkSelectionRejected -import net.blueshell.api.shared.dto.bulk.FeeCycleGroup -import net.blueshell.api.shared.enums.MemberType -import net.blueshell.api.user.api.UserService -import net.blueshell.api.user.persistence.User -import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.assertThatThrownBy -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import java.time.LocalDate - -/** - * What a send does with the plan it is handed. - * - * Both statements go out from one confirmation, each recorded in its own table, and a fee - * type naming somebody the cycle does not write to refuses the whole send. - */ -class FeeCycleUseCasesTest { - - private val periodId = 7L - private val dueDate = LocalDate.of(2026, 3, 1) - private val debitDate = LocalDate.of(2026, 3, 15) - private val dates = FeeCycleDates(paymentDue = dueDate, debit = debitDate) - - private val period = ContributionPeriod( - startDate = LocalDate.of(2025, 9, 1), - endDate = LocalDate.of(2026, 8, 31), - halfYearCutoffDate = LocalDate.of(2026, 2, 1), - halfYearFee = 25.0, - fullYearFee = 45.0, - alumniFee = 10.0, - ).seeded(periodId) - - private val planner: FeeCyclePlanner = mockk() - private val periods: ContributionPeriodService = mockk() - private val users: UserService = mockk() - private val reminders: ContributionReminderService = mockk(relaxed = true) - private val preNotifications: IncassoNotificationService = mockk(relaxed = true) - - private val useCases = FeeCycleUseCases(planner, periods, users, reminders, preNotifications) - - @Nested - inner class OneConfirmationReachesBothSides { - - @Test - fun `each side is written to and reported separately`() { - plan( - participant(1L, "Ann Debit", FeeCycleGroup.DIRECT_DEBIT), - participant(2L, "Ben Transfer", FeeCycleGroup.TRANSFER), - participant(3L, "Cara Transfer", FeeCycleGroup.TRANSFER), - ) - - val result = useCases.send(periodId, dates, emptyMap()) - - assertThat(result.paymentRequestsQueued).isEqualTo(2) - assertThat(result.preNotificationsQueued).isEqualTo(1) - assertThat(result.excluded).isEqualTo(0) - } - - @Test - fun `the payment request records the fee type it stated and the date it asked for`() { - plan(participant(2L, "Ben Transfer", FeeCycleGroup.TRANSFER, feeType = BulkFeeType.HALF_YEAR_FEE)) - val written = slot() - every { reminders.create(capture(written)) } answers { written.captured } - - useCases.send(periodId, dates, emptyMap()) - - assertThat(written.captured.feeType).isEqualTo(BulkFeeType.HALF_YEAR_FEE) - assertThat(written.captured.paymentDueDate).isEqualTo(dueDate) - } - - @Test - fun `the pre-notification records the fee type it stated and the debit date`() { - plan(participant(1L, "Ann Debit", FeeCycleGroup.DIRECT_DEBIT, feeType = BulkFeeType.ALUMNI_FEE)) - val written = slot() - every { preNotifications.create(capture(written)) } answers { written.captured } - - useCases.send(periodId, dates, emptyMap()) - - assertThat(written.captured.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) - assertThat(written.captured.debitDate).isEqualTo(debitDate) - } - - @Test - fun `an excluded member is written to on neither side and reported as excluded`() { - plan( - participant( - 1L, - "Ann Honorary", - FeeCycleGroup.TRANSFER, - disposition = BulkRowDisposition.EXCLUDED, - reason = BulkRowReason.HONORARY, - feeType = null, - ), - ) - - val result = useCases.send(periodId, dates, emptyMap()) - - assertThat(result.paymentRequestsQueued).isEqualTo(0) - assertThat(result.preNotificationsQueued).isEqualTo(0) - assertThat(result.excluded).isEqualTo(1) - verify(exactly = 0) { reminders.create(any()) } - verify(exactly = 0) { preNotifications.create(any()) } - } - } - - @Nested - inner class AskingAgain { - - /** - * The treasurer chases: a member asked in September and again in February has been - * asked twice, and a record that only remembers the second cannot say so. - */ - @Test - fun `writes another ask rather than overwriting the last one`() { - plan(participant(2L, "Ben Transfer", FeeCycleGroup.TRANSFER)) - val written = mutableListOf() - every { reminders.create(capture(written)) } answers { firstArg().seeded(1L) } - - useCases.send(periodId, dates, emptyMap()) - useCases.send(periodId, dates, emptyMap()) - - assertThat(written).hasSize(2) - verify(exactly = 0) { reminders.update(any()) } - } - - @Test - fun `writes another pre-notification too, so a moved debit date can be re-told`() { - plan(participant(1L, "Ann Debit", FeeCycleGroup.DIRECT_DEBIT)) - val written = mutableListOf() - every { preNotifications.create(capture(written)) } answers { firstArg().seeded(1L) } - - useCases.send(periodId, dates, emptyMap()) - useCases.send(periodId, FeeCycleDates(paymentDue = dueDate, debit = debitDate.plusDays(7)), emptyMap()) - - assertThat(written).hasSize(2) - assertThat(written.map { it.debitDate }).containsExactly(debitDate, debitDate.plusDays(7)) - verify(exactly = 0) { preNotifications.update(any()) } - } - } - - @Nested - inner class AFeeTypeForSomebodyTheCycleDoesNotWriteTo { - - @Test - fun `refuses the whole send and names the members at fault`() { - plan( - participant(2L, "Ben Transfer", FeeCycleGroup.TRANSFER), - participant( - 9L, - "Ann Honorary", - FeeCycleGroup.TRANSFER, - disposition = BulkRowDisposition.EXCLUDED, - reason = BulkRowReason.HONORARY, - feeType = null, - ), - ) - - assertThatThrownBy { - useCases.send(periodId, dates, mapOf(9L to BulkFeeType.ALUMNI_FEE)) - } - .isInstanceOf(BulkSelectionRejected::class.java) - .satisfies({ thrown -> - val violation = (thrown as BulkSelectionRejected).violations.single() - assertThat(violation.code).isEqualTo(BulkSelectionRejected.NON_RECIPIENT_FEE_TYPES) - assertThat(violation.values).containsExactly(9L) - }) - - verify(exactly = 0) { reminders.create(any()) } - verify(exactly = 0) { preNotifications.create(any()) } - } - - @Test - fun `a fee type for a recipient is honoured over the one that applies`() { - plan(participant(2L, "Ben Transfer", FeeCycleGroup.TRANSFER, feeType = BulkFeeType.FULL_YEAR_FEE)) - val written = slot() - every { reminders.create(capture(written)) } answers { written.captured } - - useCases.send(periodId, dates, mapOf(2L to BulkFeeType.ALUMNI_FEE)) - - assertThat(written.captured.feeType).isEqualTo(BulkFeeType.ALUMNI_FEE) - } - } - - // ── Fixture ────────────────────────────────────────────────────────────── - - private fun plan(vararg participants: FeeCycleParticipant) { - every { planner.plan(periodId) } returns FeeCyclePlan(periodId, participants.toList()) - every { periods.findById(periodId) } returns period - participants.forEach { every { users.findById(it.userId) } returns member(it.userId, it.name) } - // A relaxed mock answers create() with a generic stub, which the send path then reads - // an id off. Echo the argument, seeded with an id, so the record it wrote is the - // record it sends. - every { reminders.create(any()) } answers { firstArg().seeded(1L) } - every { preNotifications.create(any()) } answers { firstArg().seeded(1L) } - } - - private fun participant( - userId: Long, - name: String, - group: FeeCycleGroup, - disposition: BulkRowDisposition = BulkRowDisposition.INCLUDED, - reason: BulkRowReason? = null, - feeType: BulkFeeType? = BulkFeeType.FULL_YEAR_FEE, - ) = FeeCycleParticipant( - userId = userId, - name = name, - memberType = MemberType.REGULAR, - memberSince = LocalDate.of(2025, 9, 1), - group = group, - disposition = disposition, - reason = reason, - feeType = feeType, - amount = feeType?.let { resolveFeeAmount(it, period) }, - lastAskedOn = null, - ) - - private fun member(userId: Long, fullName: String): User { - val (firstName, lastName) = fullName.split(" ") - return User( - username = "member$userId", - email = "member$userId@example.com", - password = "dummy", - initials = "${firstName.first()}${lastName.first()}", - firstName = firstName, - lastName = lastName, - phoneNumber = "0612345678", - discord = "member$userId#0001", - ).seeded(userId) - } -} diff --git a/services/frontend/src/components/common/BulkActionsMenu.vue b/services/frontend/src/components/common/BulkActionsMenu.vue index db7708390..3dea58d7d 100644 --- a/services/frontend/src/components/common/BulkActionsMenu.vue +++ b/services/frontend/src/components/common/BulkActionsMenu.vue @@ -19,7 +19,7 @@ const emit = defineEmits<{ (e: "addUser"): void (e: "markPaid"): void (e: "markUnpaid"): void - (e: "feeCycle"): void + (e: "sendPaymentEmails"): void (e: "endMembership"): void (e: "startMembership"): void }>() @@ -70,16 +70,12 @@ const bulkDisabled = computed(() => !props.hasSelection || props.noPeriod) @click="emit('markUnpaid')" /> - diff --git a/services/frontend/src/services/api/blueshell/index.ts b/services/frontend/src/services/api/blueshell/index.ts index 2270d94f2..b815ff18f 100644 --- a/services/frontend/src/services/api/blueshell/index.ts +++ b/services/frontend/src/services/api/blueshell/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts -export { addMember, addRosterEntry, apply, applyInboundReconcile, approveEvent, authenticate, boardCreateMembership, clearGameAccount, correctEmail, createAddress, createBlog, createBoard, createCommittee, createContribution, createContributionPeriod, createEvent, createEventSignup, createGame, createMemberProfile, createMembership, createSeason, createSponsor, createTarget, createTeam, createTelemetry, createUser, csrf, deleteAddressById, deleteBoard, deleteById, deleteCommitteeById, deleteContribution, deleteContributionPeriodById, deleteEventById, deleteEventSignup, deleteGame, deleteMembership, deleteSeason, deleteSponsorById, deleteTeam, deleteUserById, downloadEventBanner, downloadPublicFile, endMembership, endMemberships, enqueue, enterGame, fieldTeam, findAddressById, findAllAddresses, findAllBoards, findBlogById, findBlogs, findBoardById, findCohortById, findCohorts, findCohortSubjectById, findCohortSubjects, findCommitteeById, findCommittees, findCommitteesByUserId, findContributionPeriods, findContributionReminders, findContributions, findContributionsByPeriodId, findCurrentContributionPeriod, findDeletedMemberships, findDeletedUsers, findEventById, findEvents, findEventSignUps, findEventSignUpsByAccessToken, findEventSignUpsByEventId, findGame, findGameAccounts, findGameContents, findGames, findMemberProfileByUserId, findMembershipById, findMemberships, findRoster, findSeasonContents, findSeasonGames, findSeasons, findSponsorById, findSponsors, findTeams, findTeamSeasons, findTelemetryById, findUserById, findUsers, forwardAuth, getStats, getStats1, healthCheck, jobTypes, leaveGame, linkExistingTarget, linkMember, linkRosterEntry, linkUser, list, list1, listCohortTargetFolders, listCohortTargetSystems, logout, markPaid, markUnpaid, memberActivate, moveCohortTarget, moveCohortTargets, myServices, type Options, pendingActivations, previewBulkEnd, previewBulkStart, previewFeeCycle, previewFeeCycleEmail, previewInboundReconcile, previewRecoveryEmail, previewSentEmail, removeMember, removeRosterEntry, reopenMembership, repairMissingAdds, resendRecoveryEmail, resendUserActivation, resetPassword, restoreDeletedUserById, restoreMembership, retry, retry1, saveAddress, searchCohortTargets, sendContributionReminder, sendContributionReminderBatch, sendFeeCycle, setGameAccount, setPassword, signUp, startMemberships, switchTarget, toggleUserRole, unfieldTeam, updateAddress, updateBlog, updateBoard, updateCommittee, updateContributionPeriod, updateDetails, updateEvent, updateEventSignUp, updateGame, updateMember, updateMemberProfile, updateMembership, updateRosterEntry, updateSeason, updateSponsor, updateTeam, updateUser, uploadEventBanner, uploadPublicImage, userActivate } from './sdk.gen'; -export { ActionActorType, type ActivationResponse, type Actor, type AddBoardMemberRequest, type AddMemberData, type AddMemberError, type AddMemberErrors, type AddMemberResponse, type AddMemberResponses, type AddressResponse, type AddRosterEntryData, type AddRosterEntryError, type AddRosterEntryErrors, type AddRosterEntryRequest, type AddRosterEntryResponse, type AddRosterEntryResponses, type AnswerRequest, type AnswerResponse, type ApiError, type ApplyData, type ApplyError, type ApplyErrors, type ApplyInboundReconcileData, type ApplyInboundReconcileError, type ApplyInboundReconcileErrors, type ApplyInboundReconcileResponse, type ApplyInboundReconcileResponses, type ApplyResponse, type ApplyResponses, type ApproveEventData, type ApproveEventError, type ApproveEventErrors, type ApproveEventResponse, type ApproveEventResponses, type AuthenticateData, type AuthenticateError, type AuthenticateErrors, type AuthenticateResponse, type AuthenticateResponses, type BlogResponse, type BoardCreateMembershipData, type BoardCreateMembershipError, type BoardCreateMembershipErrors, type BoardCreateMembershipRequest, type BoardCreateMembershipResponse, type BoardCreateMembershipResponses, type BoardMemberResponse, type BoardResponse, type BulkActionResult, BulkFeeType, type BulkMarkPaidRequest, type BulkMarkUnpaidRequest, type BulkMembershipPreview, type BulkMembershipPreviewRow, type BulkMembershipRequest, type BulkMoveTargetsRequest, BulkRowDisposition, BulkRowReason, type BulkRowVocabulary, type BulkTargetMoveResult, type ClearGameAccountData, type ClearGameAccountError, type ClearGameAccountErrors, type ClearGameAccountResponse, type ClearGameAccountResponses, type ClientOptions, type CohortDetail, CohortKind, type CohortMapping, type CohortMemberRow, type CohortRepair, CohortSubjectCategory, type CohortSubjectDetail, type CohortSubjectMember, type CohortSubjectSummary, CohortSubjectType, type CohortSummary, type CommitteeDetailResponse, type CommitteeMemberRequest, type CommitteeMemberResponse, type CommitteeResponse, ContactSystem, type ContributionPeriodResponse, type ContributionReminderResponse, type ContributionResponse, type CorrectEmailData, type CorrectEmailError, type CorrectEmailErrors, type CorrectEmailResponse, type CorrectEmailResponses, type CreateAddressData, type CreateAddressError, type CreateAddressErrors, type CreateAddressRequest, type CreateAddressResponse, type CreateAddressResponses, type CreateBlogData, type CreateBlogError, type CreateBlogErrors, type CreateBlogRequest, type CreateBlogResponse, type CreateBlogResponses, type CreateBoardData, type CreateBoardError, type CreateBoardErrors, type CreateBoardRequest, type CreateBoardResponse, type CreateBoardResponses, type CreateCommitteeData, type CreateCommitteeError, type CreateCommitteeErrors, type CreateCommitteeRequest, type CreateCommitteeResponse, type CreateCommitteeResponses, type CreateContributionData, type CreateContributionError, type CreateContributionErrors, type CreateContributionPeriodData, type CreateContributionPeriodError, type CreateContributionPeriodErrors, type CreateContributionPeriodRequest, type CreateContributionPeriodResponse, type CreateContributionPeriodResponses, type CreateContributionReminderRequest, type CreateContributionRequest, type CreateContributionResponse, type CreateContributionResponses, type CreateEventData, type CreateEventError, type CreateEventErrors, type CreateEventRequest, type CreateEventResponse, type CreateEventResponses, type CreateEventSignupData, type CreateEventSignupError, type CreateEventSignupErrors, type CreateEventSignUpRequest, type CreateEventSignupResponse, type CreateEventSignupResponses, type CreateGameData, type CreateGameError, type CreateGameErrors, type CreateGameRequest, type CreateGameResponse, type CreateGameResponses, type CreateGuestRequest, type CreateMemberProfileData, type CreateMemberProfileError, type CreateMemberProfileErrors, type CreateMemberProfileRequest, type CreateMemberProfileResponse, type CreateMemberProfileResponses, type CreateMembershipData, type CreateMembershipError, type CreateMembershipErrors, type CreateMembershipResponse, type CreateMembershipResponses, type CreateSeasonData, type CreateSeasonError, type CreateSeasonErrors, type CreateSeasonResponse, type CreateSeasonResponses, type CreateSponsorData, type CreateSponsorError, type CreateSponsorErrors, type CreateSponsorRequest, type CreateSponsorResponse, type CreateSponsorResponses, type CreateTargetData, type CreateTargetError, type CreateTargetErrors, type CreateTargetRequest, type CreateTargetResponse, type CreateTargetResponses, type CreateTeamData, type CreateTeamError, type CreateTeamErrors, type CreateTeamRequest, type CreateTeamResponse, type CreateTeamResponses, type CreateTelemetryData, type CreateTelemetryError, type CreateTelemetryErrors, type CreateTelemetryRequest, type CreateTelemetryResponse, type CreateTelemetryResponses, type CreateUserData, type CreateUserError, type CreateUserErrors, type CreateUserRequest, type CreateUserResponse, type CreateUserResponses, type CsrfData, type CsrfError, type CsrfErrors, type CsrfResponse, type CsrfResponses, type CsrfToken, type DeleteAddressByIdData, type DeleteAddressByIdError, type DeleteAddressByIdErrors, type DeleteAddressByIdResponse, type DeleteAddressByIdResponses, type DeleteBoardData, type DeleteBoardError, type DeleteBoardErrors, type DeleteBoardResponse, type DeleteBoardResponses, type DeleteByIdData, type DeleteByIdError, type DeleteByIdErrors, type DeleteByIdResponse, type DeleteByIdResponses, type DeleteCommitteeByIdData, type DeleteCommitteeByIdError, type DeleteCommitteeByIdErrors, type DeleteCommitteeByIdResponse, type DeleteCommitteeByIdResponses, type DeleteContributionData, type DeleteContributionError, type DeleteContributionErrors, type DeleteContributionPeriodByIdData, type DeleteContributionPeriodByIdError, type DeleteContributionPeriodByIdErrors, type DeleteContributionPeriodByIdResponse, type DeleteContributionPeriodByIdResponses, type DeleteContributionResponse, type DeleteContributionResponses, type DeleteEventByIdData, type DeleteEventByIdError, type DeleteEventByIdErrors, type DeleteEventByIdResponse, type DeleteEventByIdResponses, type DeleteEventSignupData, type DeleteEventSignupError, type DeleteEventSignupErrors, type DeleteEventSignupResponse, type DeleteEventSignupResponses, type DeleteGameData, type DeleteGameError, type DeleteGameErrors, type DeleteGameResponse, type DeleteGameResponses, type DeleteMembershipData, type DeleteMembershipError, type DeleteMembershipErrors, type DeleteMembershipResponse, type DeleteMembershipResponses, type DeleteSeasonData, type DeleteSeasonError, type DeleteSeasonErrors, type DeleteSeasonResponse, type DeleteSeasonResponses, type DeleteSponsorByIdData, type DeleteSponsorByIdError, type DeleteSponsorByIdErrors, type DeleteSponsorByIdResponse, type DeleteSponsorByIdResponses, type DeleteTeamData, type DeleteTeamError, type DeleteTeamErrors, type DeleteTeamResponse, type DeleteTeamResponses, type DeleteUserByIdData, type DeleteUserByIdError, type DeleteUserByIdErrors, type DeleteUserByIdResponse, type DeleteUserByIdResponses, type DownloadEventBannerData, type DownloadEventBannerError, type DownloadEventBannerErrors, type DownloadEventBannerResponse, type DownloadEventBannerResponses, type DownloadPublicFileData, type DownloadPublicFileError, type DownloadPublicFileErrors, type DownloadPublicFileResponse, type DownloadPublicFileResponses, type Email, EmailDeliveryStatus, type EmailStats, type EndMembershipData, type EndMembershipError, type EndMembershipErrors, type EndMembershipResponse, type EndMembershipResponses, type EndMembershipsData, type EndMembershipsError, type EndMembershipsErrors, type EndMembershipsResponse, type EndMembershipsResponses, type EnqueueData, type EnqueueError, type EnqueueErrors, type EnqueueJobRequest, type EnqueueResponse, type EnqueueResponses, type EnterGameData, type EnterGameError, type EnterGameErrors, type EnterGameResponse, type EnterGameResponses, type EventBannerRequest, type EventBannerResponse, type EventResponse, type EventSignUpResponse, type ExternalTarget, type FailedTargetMove, type FeeCycleEmailPreviewResponse, FeeCycleGroup, type FeeCyclePreviewResponse, type FeeCycleResultResponse, type FeeCycleRowResponse, type FieldedTeamResponse, type FieldingResponse, type FieldTeamData, type FieldTeamError, type FieldTeamErrors, type FieldTeamRequest, type FieldTeamResponse, type FieldTeamResponses, type FieldValidationError, type FileResponse, FileType, type FindAddressByIdData, type FindAddressByIdError, type FindAddressByIdErrors, type FindAddressByIdResponse, type FindAddressByIdResponses, type FindAllAddressesData, type FindAllAddressesError, type FindAllAddressesErrors, type FindAllAddressesResponse, type FindAllAddressesResponses, type FindAllBoardsData, type FindAllBoardsError, type FindAllBoardsErrors, type FindAllBoardsResponse, type FindAllBoardsResponses, type FindBlogByIdData, type FindBlogByIdError, type FindBlogByIdErrors, type FindBlogByIdResponse, type FindBlogByIdResponses, type FindBlogsData, type FindBlogsError, type FindBlogsErrors, type FindBlogsResponse, type FindBlogsResponses, type FindBoardByIdData, type FindBoardByIdError, type FindBoardByIdErrors, type FindBoardByIdResponse, type FindBoardByIdResponses, type FindCohortByIdData, type FindCohortByIdError, type FindCohortByIdErrors, type FindCohortByIdResponse, type FindCohortByIdResponses, type FindCohortsData, type FindCohortsError, type FindCohortsErrors, type FindCohortsResponse, type FindCohortsResponses, type FindCohortSubjectByIdData, type FindCohortSubjectByIdError, type FindCohortSubjectByIdErrors, type FindCohortSubjectByIdResponse, type FindCohortSubjectByIdResponses, type FindCohortSubjectsData, type FindCohortSubjectsError, type FindCohortSubjectsErrors, type FindCohortSubjectsResponse, type FindCohortSubjectsResponses, type FindCommitteeByIdData, type FindCommitteeByIdError, type FindCommitteeByIdErrors, type FindCommitteeByIdResponse, type FindCommitteeByIdResponses, type FindCommitteesByUserIdData, type FindCommitteesByUserIdError, type FindCommitteesByUserIdErrors, type FindCommitteesByUserIdResponse, type FindCommitteesByUserIdResponses, type FindCommitteesData, type FindCommitteesError, type FindCommitteesErrors, type FindCommitteesResponse, type FindCommitteesResponses, type FindContributionPeriodsData, type FindContributionPeriodsError, type FindContributionPeriodsErrors, type FindContributionPeriodsResponse, type FindContributionPeriodsResponses, type FindContributionRemindersData, type FindContributionRemindersError, type FindContributionRemindersErrors, type FindContributionRemindersResponse, type FindContributionRemindersResponses, type FindContributionsByPeriodIdData, type FindContributionsByPeriodIdError, type FindContributionsByPeriodIdErrors, type FindContributionsByPeriodIdResponse, type FindContributionsByPeriodIdResponses, type FindContributionsData, type FindContributionsError, type FindContributionsErrors, type FindContributionsResponse, type FindContributionsResponses, type FindCurrentContributionPeriodData, type FindCurrentContributionPeriodError, type FindCurrentContributionPeriodErrors, type FindCurrentContributionPeriodResponse, type FindCurrentContributionPeriodResponses, type FindDeletedMembershipsData, type FindDeletedMembershipsError, type FindDeletedMembershipsErrors, type FindDeletedMembershipsResponse, type FindDeletedMembershipsResponses, type FindDeletedUsersData, type FindDeletedUsersError, type FindDeletedUsersErrors, type FindDeletedUsersResponse, type FindDeletedUsersResponses, type FindEventByIdData, type FindEventByIdError, type FindEventByIdErrors, type FindEventByIdResponse, type FindEventByIdResponses, type FindEventsData, type FindEventsError, type FindEventsErrors, type FindEventSignUpsByAccessTokenData, type FindEventSignUpsByAccessTokenError, type FindEventSignUpsByAccessTokenErrors, type FindEventSignUpsByAccessTokenResponse, type FindEventSignUpsByAccessTokenResponses, type FindEventSignUpsByEventIdData, type FindEventSignUpsByEventIdError, type FindEventSignUpsByEventIdErrors, type FindEventSignUpsByEventIdResponse, type FindEventSignUpsByEventIdResponses, type FindEventSignUpsData, type FindEventSignUpsError, type FindEventSignUpsErrors, type FindEventSignUpsResponse, type FindEventSignUpsResponses, type FindEventsResponse, type FindEventsResponses, type FindGameAccountsData, type FindGameAccountsError, type FindGameAccountsErrors, type FindGameAccountsResponse, type FindGameAccountsResponses, type FindGameContentsData, type FindGameContentsError, type FindGameContentsErrors, type FindGameContentsResponse, type FindGameContentsResponses, type FindGameData, type FindGameError, type FindGameErrors, type FindGameResponse, type FindGameResponses, type FindGamesData, type FindGamesError, type FindGamesErrors, type FindGamesResponse, type FindGamesResponses, type FindMemberProfileByUserIdData, type FindMemberProfileByUserIdError, type FindMemberProfileByUserIdErrors, type FindMemberProfileByUserIdResponse, type FindMemberProfileByUserIdResponses, type FindMembershipByIdData, type FindMembershipByIdError, type FindMembershipByIdErrors, type FindMembershipByIdResponse, type FindMembershipByIdResponses, type FindMembershipsData, type FindMembershipsError, type FindMembershipsErrors, type FindMembershipsResponse, type FindMembershipsResponses, type FindRosterData, type FindRosterError, type FindRosterErrors, type FindRosterResponse, type FindRosterResponses, type FindSeasonContentsData, type FindSeasonContentsError, type FindSeasonContentsErrors, type FindSeasonContentsResponse, type FindSeasonContentsResponses, type FindSeasonGamesData, type FindSeasonGamesError, type FindSeasonGamesErrors, type FindSeasonGamesResponse, type FindSeasonGamesResponses, type FindSeasonsData, type FindSeasonsError, type FindSeasonsErrors, type FindSeasonsResponse, type FindSeasonsResponses, type FindSponsorByIdData, type FindSponsorByIdError, type FindSponsorByIdErrors, type FindSponsorByIdResponse, type FindSponsorByIdResponses, type FindSponsorsData, type FindSponsorsError, type FindSponsorsErrors, type FindSponsorsResponse, type FindSponsorsResponses, type FindTeamsData, type FindTeamSeasonsData, type FindTeamSeasonsError, type FindTeamSeasonsErrors, type FindTeamSeasonsResponse, type FindTeamSeasonsResponses, type FindTeamsError, type FindTeamsErrors, type FindTeamsResponse, type FindTeamsResponses, type FindTelemetryByIdData, type FindTelemetryByIdError, type FindTelemetryByIdErrors, type FindTelemetryByIdResponse, type FindTelemetryByIdResponses, type FindUserByIdData, type FindUserByIdError, type FindUserByIdErrors, type FindUserByIdResponse, type FindUserByIdResponses, type FindUsersData, type FindUsersError, type FindUsersErrors, type FindUsersResponse, type FindUsersResponses, type ForwardAuthData, type ForwardAuthError, type ForwardAuthErrors, type ForwardAuthResponses, type GameAccountRequest, type GameAccountResponse, type GameContentsResponse, type GameResponse, type GameRostersResponse, type GetStats1Data, type GetStats1Error, type GetStats1Errors, type GetStats1Response, type GetStats1Responses, type GetStatsData, type GetStatsError, type GetStatsErrors, type GetStatsResponse, type GetStatsResponses, type GuestResponse, type HealthCheckData, type HealthCheckError, type HealthCheckErrors, type HealthCheckResponse, type HealthCheckResponses, type Image, type ImageRendition, type InboundReconcileApplyRequest, type InboundReconcileApplyResponse, type InboundReconcilePreview, type InboundReconcileRow, type JobExecution, JobExecutionCategory, type JobExecutionRelatedEntity, JobExecutionStatus, type JobPayloadField, JobPayloadFieldKind, type JobStatsDto, type JobTypeDescriptor, type JobTypesData, type JobTypesError, type JobTypesErrors, type JobTypesResponse, type JobTypesResponses, type JwtRequest, type LeaveGameData, type LeaveGameError, type LeaveGameErrors, type LeaveGameResponse, type LeaveGameResponses, type LineupSourceRequest, type LinkBoardMemberRequest, type LinkedUser, type LinkExistingTargetData, type LinkExistingTargetError, type LinkExistingTargetErrors, type LinkExistingTargetRequest, type LinkExistingTargetResponse, type LinkExistingTargetResponses, type LinkMemberData, type LinkMemberError, type LinkMemberErrors, type LinkMemberResponse, type LinkMemberResponses, type LinkRosterEntryData, type LinkRosterEntryError, type LinkRosterEntryErrors, type LinkRosterEntryRequest, type LinkRosterEntryResponse, type LinkRosterEntryResponses, type LinkUserData, type LinkUserError, type LinkUserErrors, type LinkUserRequest, type LinkUserResponse, type LinkUserResponses, type List1Data, type List1Error, type List1Errors, type List1Response, type List1Responses, type ListCohortTargetFoldersData, type ListCohortTargetFoldersError, type ListCohortTargetFoldersErrors, type ListCohortTargetFoldersResponse, type ListCohortTargetFoldersResponses, type ListCohortTargetSystemsData, type ListCohortTargetSystemsError, type ListCohortTargetSystemsErrors, type ListCohortTargetSystemsResponse, type ListCohortTargetSystemsResponses, type ListData, type ListError, type ListErrors, type ListResponse, type ListResponses, type LoginResponse, type LogoutData, type LogoutError, type LogoutErrors, type LogoutResponse, type LogoutResponses, type MarkPaidData, type MarkPaidError, type MarkPaidErrors, type MarkPaidResponse, type MarkPaidResponses, type MarkUnpaidData, type MarkUnpaidError, type MarkUnpaidErrors, type MarkUnpaidResponse, type MarkUnpaidResponses, type MemberActivateData, type MemberActivateError, type MemberActivateErrors, type MemberActivateResponse, type MemberActivateResponses, type MemberActivationRequest, type MemberProfileResponse, type MembershipApplicationRequest, type MembershipResponse, MemberType, type MoveCohortTargetData, type MoveCohortTargetError, type MoveCohortTargetErrors, type MoveCohortTargetResponse, type MoveCohortTargetResponses, type MoveCohortTargetsData, type MoveCohortTargetsError, type MoveCohortTargetsErrors, type MoveCohortTargetsResponse, type MoveCohortTargetsResponses, type MoveTargetRequest, type MyServicesData, type MyServicesError, type MyServicesErrors, type MyServicesResponse, type MyServicesResponses, type PagedModelEmail, type PagedModelEventResponse, type PagedModelJobExecution, type PagedModelUserDetailResponse, type PageMetadata, type PasswordResetRequest, type PendingActivation, type PendingActivationsData, type PendingActivationsError, type PendingActivationsErrors, type PendingActivationsResponse, type PendingActivationsResponse2, type PendingActivationsResponses, PlatformType, type PreviewBulkEndData, type PreviewBulkEndError, type PreviewBulkEndErrors, type PreviewBulkEndResponse, type PreviewBulkEndResponses, type PreviewBulkStartData, type PreviewBulkStartError, type PreviewBulkStartErrors, type PreviewBulkStartResponse, type PreviewBulkStartResponses, type PreviewFeeCycleData, type PreviewFeeCycleEmailData, type PreviewFeeCycleEmailError, type PreviewFeeCycleEmailErrors, type PreviewFeeCycleEmailResponse, type PreviewFeeCycleEmailResponses, type PreviewFeeCycleError, type PreviewFeeCycleErrors, type PreviewFeeCycleResponse, type PreviewFeeCycleResponses, type PreviewInboundReconcileData, type PreviewInboundReconcileError, type PreviewInboundReconcileErrors, type PreviewInboundReconcileResponse, type PreviewInboundReconcileResponses, type PreviewRecoveryEmailData, type PreviewRecoveryEmailError, type PreviewRecoveryEmailErrors, type PreviewRecoveryEmailResponse, type PreviewRecoveryEmailResponses, type PreviewSentEmailData, type PreviewSentEmailError, type PreviewSentEmailErrors, type PreviewSentEmailResponse, type PreviewSentEmailResponses, type QuestionRequest, type QuestionResponse, QuestionType, type RecoveryEmailPreviewResponse, type RedirectResponse, type RemoveMemberData, type RemoveMemberError, type RemoveMemberErrors, type RemoveMemberResponse, type RemoveMemberResponses, type RemoveRosterEntryData, type RemoveRosterEntryError, type RemoveRosterEntryErrors, type RemoveRosterEntryResponse, type RemoveRosterEntryResponses, type ReopenMembershipData, type ReopenMembershipError, type ReopenMembershipErrors, type ReopenMembershipResponse, type ReopenMembershipResponses, type RepairMissingAddsData, type RepairMissingAddsError, type RepairMissingAddsErrors, type RepairMissingAddsResponse, type RepairMissingAddsResponses, type ResendRecoveryEmailData, type ResendRecoveryEmailError, type ResendRecoveryEmailErrors, type ResendRecoveryEmailResponse, type ResendRecoveryEmailResponses, type ResendUserActivationData, type ResendUserActivationError, type ResendUserActivationErrors, type ResendUserActivationResponse, type ResendUserActivationResponses, type ResetPasswordData, type ResetPasswordError, type ResetPasswordErrors, type ResetPasswordResponse, type ResetPasswordResponses, type RestoreDeletedUserByIdData, type RestoreDeletedUserByIdError, type RestoreDeletedUserByIdErrors, type RestoreDeletedUserByIdResponse, type RestoreDeletedUserByIdResponses, type RestoreMembershipData, type RestoreMembershipError, type RestoreMembershipErrors, type RestoreMembershipResponse, type RestoreMembershipResponses, type Retry1Data, type Retry1Error, type Retry1Errors, type Retry1Response, type Retry1Responses, type RetryData, type RetryError, type RetryErrors, type RetryResponse, type RetryResponses, Role, type RosterEntryResponse, type RosterMemberResponse, type SaveAddressData, type SaveAddressError, type SaveAddressErrors, type SaveAddressResponse, type SaveAddressResponses, type SearchCohortTargetsData, type SearchCohortTargetsError, type SearchCohortTargetsErrors, type SearchCohortTargetsResponse, type SearchCohortTargetsResponses, type SeasonContentsResponse, type SeasonGameResponse, type SeasonRequest, type SeasonResponse, type SendContributionReminderBatchData, type SendContributionReminderBatchError, type SendContributionReminderBatchErrors, type SendContributionReminderBatchResponse, type SendContributionReminderBatchResponses, type SendContributionReminderData, type SendContributionReminderError, type SendContributionReminderErrors, type SendContributionReminderResponse, type SendContributionReminderResponses, type SendFeeCycleData, type SendFeeCycleError, type SendFeeCycleErrors, type SendFeeCycleRequest, type SendFeeCycleResponse, type SendFeeCycleResponses, type SentEmailPreview, type ServiceEntry, type SetGameAccountData, type SetGameAccountError, type SetGameAccountErrors, type SetGameAccountResponse, type SetGameAccountResponses, type SetPasswordData, type SetPasswordError, type SetPasswordErrors, type SetPasswordResponse, type SetPasswordResponses, type SignupAddressRequest, type SignupApplicationRequest, type SignUpData, type SignupDetailsRequest, type SignupEmailRequest, type SignUpError, type SignUpErrors, type SignupOutcomeResponse, type SignUpResponse, type SignUpResponses, type SignupSessionResponse, type SponsorResponse, type StartMembershipsData, type StartMembershipsError, type StartMembershipsErrors, type StartMembershipsResponse, type StartMembershipsResponses, type SurveyRequest, type SurveyResponse, type SwitchTargetData, type SwitchTargetError, type SwitchTargetErrors, type SwitchTargetRequest, type SwitchTargetResponse, type SwitchTargetResponses, type TargetDescriptor, TargetSystem, type TeamResponse, TeamRole, type TeamRosterResponse, type TelemetryResponse, type ToggleUserRoleData, type ToggleUserRoleError, type ToggleUserRoleErrors, type ToggleUserRoleResponse, type ToggleUserRoleResponses, TokenPurpose, type UnfieldTeamData, type UnfieldTeamError, type UnfieldTeamErrors, type UnfieldTeamResponse, type UnfieldTeamResponses, type UpdateAddressData, type UpdateAddressError, type UpdateAddressErrors, type UpdateAddressRequest, type UpdateAddressResponse, type UpdateAddressResponses, type UpdateBlogData, type UpdateBlogError, type UpdateBlogErrors, type UpdateBlogRequest, type UpdateBlogResponse, type UpdateBlogResponses, type UpdateBoardData, type UpdateBoardError, type UpdateBoardErrors, type UpdateBoardMemberRequest, type UpdateBoardRequest, type UpdateBoardResponse, type UpdateBoardResponses, type UpdateCommitteeData, type UpdateCommitteeError, type UpdateCommitteeErrors, type UpdateCommitteeRequest, type UpdateCommitteeResponse, type UpdateCommitteeResponses, type UpdateContributionPeriodData, type UpdateContributionPeriodError, type UpdateContributionPeriodErrors, type UpdateContributionPeriodRequest, type UpdateContributionPeriodResponse, type UpdateContributionPeriodResponses, type UpdateDetailsData, type UpdateDetailsError, type UpdateDetailsErrors, type UpdateDetailsResponse, type UpdateDetailsResponses, type UpdateEventData, type UpdateEventError, type UpdateEventErrors, type UpdateEventRequest, type UpdateEventResponse, type UpdateEventResponses, type UpdateEventSignUpData, type UpdateEventSignUpError, type UpdateEventSignUpErrors, type UpdateEventSignUpRequest, type UpdateEventSignUpResponse, type UpdateEventSignUpResponses, type UpdateGameData, type UpdateGameError, type UpdateGameErrors, type UpdateGameRequest, type UpdateGameResponse, type UpdateGameResponses, type UpdateMemberData, type UpdateMemberError, type UpdateMemberErrors, type UpdateMemberProfileData, type UpdateMemberProfileError, type UpdateMemberProfileErrors, type UpdateMemberProfileRequest, type UpdateMemberProfileResponse, type UpdateMemberProfileResponses, type UpdateMemberResponse, type UpdateMemberResponses, type UpdateMembershipData, type UpdateMembershipError, type UpdateMembershipErrors, type UpdateMembershipRequest, type UpdateMembershipResponse, type UpdateMembershipResponses, type UpdateRosterEntryData, type UpdateRosterEntryError, type UpdateRosterEntryErrors, type UpdateRosterEntryRequest, type UpdateRosterEntryResponse, type UpdateRosterEntryResponses, type UpdateSeasonData, type UpdateSeasonError, type UpdateSeasonErrors, type UpdateSeasonResponse, type UpdateSeasonResponses, type UpdateSponsorData, type UpdateSponsorError, type UpdateSponsorErrors, type UpdateSponsorRequest, type UpdateSponsorResponse, type UpdateSponsorResponses, type UpdateTeamData, type UpdateTeamError, type UpdateTeamErrors, type UpdateTeamRequest, type UpdateTeamResponse, type UpdateTeamResponses, type UpdateUserData, type UpdateUserError, type UpdateUserErrors, type UpdateUserRequest, type UpdateUserResponse, type UpdateUserResponses, type UploadEventBannerData, type UploadEventBannerError, type UploadEventBannerErrors, type UploadEventBannerResponse, type UploadEventBannerResponses, type UploadPublicImageData, type UploadPublicImageError, type UploadPublicImageErrors, type UploadPublicImageResponse, type UploadPublicImageResponses, type UpsertMemberProfileRequest, type UserActivateData, type UserActivateError, type UserActivateErrors, type UserActivateResponse, type UserActivateResponses, type UserActivationRequest, type UserDetailResponse, type UserSummaryResponse } from './types.gen'; +export { addMember, addRosterEntry, apply, applyInboundReconcile, approveEvent, authenticate, boardCreateMembership, clearGameAccount, correctEmail, createAddress, createBlog, createBoard, createCommittee, createContribution, createContributionPeriod, createEvent, createEventSignup, createGame, createMemberProfile, createMembership, createSeason, createSponsor, createTarget, createTeam, createTelemetry, createUser, csrf, deleteAddressById, deleteBoard, deleteById, deleteCommitteeById, deleteContribution, deleteContributionPeriodById, deleteEventById, deleteEventSignup, deleteGame, deleteMembership, deleteSeason, deleteSponsorById, deleteTeam, deleteUserById, downloadEventBanner, downloadPublicFile, endMembership, endMemberships, enqueue, enterGame, fieldTeam, findAddressById, findAllAddresses, findAllBoards, findBlogById, findBlogs, findBoardById, findCohortById, findCohorts, findCohortSubjectById, findCohortSubjects, findCommitteeById, findCommittees, findCommitteesByUserId, findContributionPeriods, findContributionReminders, findContributions, findContributionsByPeriodId, findCurrentContributionPeriod, findDeletedMemberships, findDeletedUsers, findEventById, findEvents, findEventSignUps, findEventSignUpsByAccessToken, findEventSignUpsByEventId, findGame, findGameAccounts, findGameContents, findGames, findMemberProfileByUserId, findMembershipById, findMemberships, findRoster, findSeasonContents, findSeasonGames, findSeasons, findSponsorById, findSponsors, findTeams, findTeamSeasons, findTelemetryById, findUserById, findUsers, forwardAuth, getStats, getStats1, healthCheck, jobTypes, leaveGame, linkExistingTarget, linkMember, linkRosterEntry, linkUser, list, list1, listCohortTargetFolders, listCohortTargetSystems, logout, markPaid, markUnpaid, memberActivate, moveCohortTarget, moveCohortTargets, myServices, type Options, pendingActivations, previewBulkContributionEmail, previewBulkEnd, previewBulkStart, previewInboundReconcile, previewRecoveryEmail, previewSentEmail, readContributionEmail, removeMember, removeRosterEntry, reopenMembership, repairMissingAdds, resendRecoveryEmail, resendUserActivation, resetPassword, restoreDeletedUserById, restoreMembership, retry, retry1, saveAddress, searchCohortTargets, sendContributionReminder, sendContributionReminderBatch, sendPaymentEmails, setGameAccount, setPassword, signUp, startMemberships, switchTarget, toggleUserRole, unfieldTeam, updateAddress, updateBlog, updateBoard, updateCommittee, updateContributionPeriod, updateDetails, updateEvent, updateEventSignUp, updateGame, updateMember, updateMemberProfile, updateMembership, updateRosterEntry, updateSeason, updateSponsor, updateTeam, updateUser, uploadEventBanner, uploadPublicImage, userActivate } from './sdk.gen'; +export { ActionActorType, type ActivationResponse, type Actor, type AddBoardMemberRequest, type AddMemberData, type AddMemberError, type AddMemberErrors, type AddMemberResponse, type AddMemberResponses, type AddressResponse, type AddRosterEntryData, type AddRosterEntryError, type AddRosterEntryErrors, type AddRosterEntryRequest, type AddRosterEntryResponse, type AddRosterEntryResponses, type AnswerRequest, type AnswerResponse, type ApiError, type ApplyData, type ApplyError, type ApplyErrors, type ApplyInboundReconcileData, type ApplyInboundReconcileError, type ApplyInboundReconcileErrors, type ApplyInboundReconcileResponse, type ApplyInboundReconcileResponses, type ApplyResponse, type ApplyResponses, type ApproveEventData, type ApproveEventError, type ApproveEventErrors, type ApproveEventResponse, type ApproveEventResponses, type AuthenticateData, type AuthenticateError, type AuthenticateErrors, type AuthenticateResponse, type AuthenticateResponses, type BlogResponse, type BoardCreateMembershipData, type BoardCreateMembershipError, type BoardCreateMembershipErrors, type BoardCreateMembershipRequest, type BoardCreateMembershipResponse, type BoardCreateMembershipResponses, type BoardMemberResponse, type BoardResponse, type BulkActionResult, type BulkContributionEmailPreviewRequest, type BulkContributionEmailPreviewResponse, type BulkContributionEmailRowResponse, BulkFeeType, type BulkMarkPaidRequest, type BulkMarkUnpaidRequest, type BulkMembershipPreview, type BulkMembershipPreviewRow, type BulkMembershipRequest, type BulkMoveTargetsRequest, BulkRowDisposition, BulkRowReason, type BulkRowVocabulary, type BulkTargetMoveResult, type ClearGameAccountData, type ClearGameAccountError, type ClearGameAccountErrors, type ClearGameAccountResponse, type ClearGameAccountResponses, type ClientOptions, type CohortDetail, CohortKind, type CohortMapping, type CohortMemberRow, type CohortRepair, CohortSubjectCategory, type CohortSubjectDetail, type CohortSubjectMember, type CohortSubjectSummary, CohortSubjectType, type CohortSummary, type CommitteeDetailResponse, type CommitteeMemberRequest, type CommitteeMemberResponse, type CommitteeResponse, ContactSystem, ContributionEmailKind, type ContributionEmailMessageResponse, type ContributionPeriodResponse, type ContributionReminderResponse, type ContributionResponse, type CorrectEmailData, type CorrectEmailError, type CorrectEmailErrors, type CorrectEmailResponse, type CorrectEmailResponses, type CreateAddressData, type CreateAddressError, type CreateAddressErrors, type CreateAddressRequest, type CreateAddressResponse, type CreateAddressResponses, type CreateBlogData, type CreateBlogError, type CreateBlogErrors, type CreateBlogRequest, type CreateBlogResponse, type CreateBlogResponses, type CreateBoardData, type CreateBoardError, type CreateBoardErrors, type CreateBoardRequest, type CreateBoardResponse, type CreateBoardResponses, type CreateCommitteeData, type CreateCommitteeError, type CreateCommitteeErrors, type CreateCommitteeRequest, type CreateCommitteeResponse, type CreateCommitteeResponses, type CreateContributionData, type CreateContributionError, type CreateContributionErrors, type CreateContributionPeriodData, type CreateContributionPeriodError, type CreateContributionPeriodErrors, type CreateContributionPeriodRequest, type CreateContributionPeriodResponse, type CreateContributionPeriodResponses, type CreateContributionReminderRequest, type CreateContributionRequest, type CreateContributionResponse, type CreateContributionResponses, type CreateEventData, type CreateEventError, type CreateEventErrors, type CreateEventRequest, type CreateEventResponse, type CreateEventResponses, type CreateEventSignupData, type CreateEventSignupError, type CreateEventSignupErrors, type CreateEventSignUpRequest, type CreateEventSignupResponse, type CreateEventSignupResponses, type CreateGameData, type CreateGameError, type CreateGameErrors, type CreateGameRequest, type CreateGameResponse, type CreateGameResponses, type CreateGuestRequest, type CreateMemberProfileData, type CreateMemberProfileError, type CreateMemberProfileErrors, type CreateMemberProfileRequest, type CreateMemberProfileResponse, type CreateMemberProfileResponses, type CreateMembershipData, type CreateMembershipError, type CreateMembershipErrors, type CreateMembershipResponse, type CreateMembershipResponses, type CreateSeasonData, type CreateSeasonError, type CreateSeasonErrors, type CreateSeasonResponse, type CreateSeasonResponses, type CreateSponsorData, type CreateSponsorError, type CreateSponsorErrors, type CreateSponsorRequest, type CreateSponsorResponse, type CreateSponsorResponses, type CreateTargetData, type CreateTargetError, type CreateTargetErrors, type CreateTargetRequest, type CreateTargetResponse, type CreateTargetResponses, type CreateTeamData, type CreateTeamError, type CreateTeamErrors, type CreateTeamRequest, type CreateTeamResponse, type CreateTeamResponses, type CreateTelemetryData, type CreateTelemetryError, type CreateTelemetryErrors, type CreateTelemetryRequest, type CreateTelemetryResponse, type CreateTelemetryResponses, type CreateUserData, type CreateUserError, type CreateUserErrors, type CreateUserRequest, type CreateUserResponse, type CreateUserResponses, type CsrfData, type CsrfError, type CsrfErrors, type CsrfResponse, type CsrfResponses, type CsrfToken, type DeleteAddressByIdData, type DeleteAddressByIdError, type DeleteAddressByIdErrors, type DeleteAddressByIdResponse, type DeleteAddressByIdResponses, type DeleteBoardData, type DeleteBoardError, type DeleteBoardErrors, type DeleteBoardResponse, type DeleteBoardResponses, type DeleteByIdData, type DeleteByIdError, type DeleteByIdErrors, type DeleteByIdResponse, type DeleteByIdResponses, type DeleteCommitteeByIdData, type DeleteCommitteeByIdError, type DeleteCommitteeByIdErrors, type DeleteCommitteeByIdResponse, type DeleteCommitteeByIdResponses, type DeleteContributionData, type DeleteContributionError, type DeleteContributionErrors, type DeleteContributionPeriodByIdData, type DeleteContributionPeriodByIdError, type DeleteContributionPeriodByIdErrors, type DeleteContributionPeriodByIdResponse, type DeleteContributionPeriodByIdResponses, type DeleteContributionResponse, type DeleteContributionResponses, type DeleteEventByIdData, type DeleteEventByIdError, type DeleteEventByIdErrors, type DeleteEventByIdResponse, type DeleteEventByIdResponses, type DeleteEventSignupData, type DeleteEventSignupError, type DeleteEventSignupErrors, type DeleteEventSignupResponse, type DeleteEventSignupResponses, type DeleteGameData, type DeleteGameError, type DeleteGameErrors, type DeleteGameResponse, type DeleteGameResponses, type DeleteMembershipData, type DeleteMembershipError, type DeleteMembershipErrors, type DeleteMembershipResponse, type DeleteMembershipResponses, type DeleteSeasonData, type DeleteSeasonError, type DeleteSeasonErrors, type DeleteSeasonResponse, type DeleteSeasonResponses, type DeleteSponsorByIdData, type DeleteSponsorByIdError, type DeleteSponsorByIdErrors, type DeleteSponsorByIdResponse, type DeleteSponsorByIdResponses, type DeleteTeamData, type DeleteTeamError, type DeleteTeamErrors, type DeleteTeamResponse, type DeleteTeamResponses, type DeleteUserByIdData, type DeleteUserByIdError, type DeleteUserByIdErrors, type DeleteUserByIdResponse, type DeleteUserByIdResponses, type DownloadEventBannerData, type DownloadEventBannerError, type DownloadEventBannerErrors, type DownloadEventBannerResponse, type DownloadEventBannerResponses, type DownloadPublicFileData, type DownloadPublicFileError, type DownloadPublicFileErrors, type DownloadPublicFileResponse, type DownloadPublicFileResponses, type Email, EmailDeliveryStatus, type EmailStats, type EndMembershipData, type EndMembershipError, type EndMembershipErrors, type EndMembershipResponse, type EndMembershipResponses, type EndMembershipsData, type EndMembershipsError, type EndMembershipsErrors, type EndMembershipsResponse, type EndMembershipsResponses, type EnqueueData, type EnqueueError, type EnqueueErrors, type EnqueueJobRequest, type EnqueueResponse, type EnqueueResponses, type EnterGameData, type EnterGameError, type EnterGameErrors, type EnterGameResponse, type EnterGameResponses, type EventBannerRequest, type EventBannerResponse, type EventResponse, type EventSignUpResponse, type ExternalTarget, type FailedTargetMove, type FieldedTeamResponse, type FieldingResponse, type FieldTeamData, type FieldTeamError, type FieldTeamErrors, type FieldTeamRequest, type FieldTeamResponse, type FieldTeamResponses, type FieldValidationError, type FileResponse, FileType, type FindAddressByIdData, type FindAddressByIdError, type FindAddressByIdErrors, type FindAddressByIdResponse, type FindAddressByIdResponses, type FindAllAddressesData, type FindAllAddressesError, type FindAllAddressesErrors, type FindAllAddressesResponse, type FindAllAddressesResponses, type FindAllBoardsData, type FindAllBoardsError, type FindAllBoardsErrors, type FindAllBoardsResponse, type FindAllBoardsResponses, type FindBlogByIdData, type FindBlogByIdError, type FindBlogByIdErrors, type FindBlogByIdResponse, type FindBlogByIdResponses, type FindBlogsData, type FindBlogsError, type FindBlogsErrors, type FindBlogsResponse, type FindBlogsResponses, type FindBoardByIdData, type FindBoardByIdError, type FindBoardByIdErrors, type FindBoardByIdResponse, type FindBoardByIdResponses, type FindCohortByIdData, type FindCohortByIdError, type FindCohortByIdErrors, type FindCohortByIdResponse, type FindCohortByIdResponses, type FindCohortsData, type FindCohortsError, type FindCohortsErrors, type FindCohortsResponse, type FindCohortsResponses, type FindCohortSubjectByIdData, type FindCohortSubjectByIdError, type FindCohortSubjectByIdErrors, type FindCohortSubjectByIdResponse, type FindCohortSubjectByIdResponses, type FindCohortSubjectsData, type FindCohortSubjectsError, type FindCohortSubjectsErrors, type FindCohortSubjectsResponse, type FindCohortSubjectsResponses, type FindCommitteeByIdData, type FindCommitteeByIdError, type FindCommitteeByIdErrors, type FindCommitteeByIdResponse, type FindCommitteeByIdResponses, type FindCommitteesByUserIdData, type FindCommitteesByUserIdError, type FindCommitteesByUserIdErrors, type FindCommitteesByUserIdResponse, type FindCommitteesByUserIdResponses, type FindCommitteesData, type FindCommitteesError, type FindCommitteesErrors, type FindCommitteesResponse, type FindCommitteesResponses, type FindContributionPeriodsData, type FindContributionPeriodsError, type FindContributionPeriodsErrors, type FindContributionPeriodsResponse, type FindContributionPeriodsResponses, type FindContributionRemindersData, type FindContributionRemindersError, type FindContributionRemindersErrors, type FindContributionRemindersResponse, type FindContributionRemindersResponses, type FindContributionsByPeriodIdData, type FindContributionsByPeriodIdError, type FindContributionsByPeriodIdErrors, type FindContributionsByPeriodIdResponse, type FindContributionsByPeriodIdResponses, type FindContributionsData, type FindContributionsError, type FindContributionsErrors, type FindContributionsResponse, type FindContributionsResponses, type FindCurrentContributionPeriodData, type FindCurrentContributionPeriodError, type FindCurrentContributionPeriodErrors, type FindCurrentContributionPeriodResponse, type FindCurrentContributionPeriodResponses, type FindDeletedMembershipsData, type FindDeletedMembershipsError, type FindDeletedMembershipsErrors, type FindDeletedMembershipsResponse, type FindDeletedMembershipsResponses, type FindDeletedUsersData, type FindDeletedUsersError, type FindDeletedUsersErrors, type FindDeletedUsersResponse, type FindDeletedUsersResponses, type FindEventByIdData, type FindEventByIdError, type FindEventByIdErrors, type FindEventByIdResponse, type FindEventByIdResponses, type FindEventsData, type FindEventsError, type FindEventsErrors, type FindEventSignUpsByAccessTokenData, type FindEventSignUpsByAccessTokenError, type FindEventSignUpsByAccessTokenErrors, type FindEventSignUpsByAccessTokenResponse, type FindEventSignUpsByAccessTokenResponses, type FindEventSignUpsByEventIdData, type FindEventSignUpsByEventIdError, type FindEventSignUpsByEventIdErrors, type FindEventSignUpsByEventIdResponse, type FindEventSignUpsByEventIdResponses, type FindEventSignUpsData, type FindEventSignUpsError, type FindEventSignUpsErrors, type FindEventSignUpsResponse, type FindEventSignUpsResponses, type FindEventsResponse, type FindEventsResponses, type FindGameAccountsData, type FindGameAccountsError, type FindGameAccountsErrors, type FindGameAccountsResponse, type FindGameAccountsResponses, type FindGameContentsData, type FindGameContentsError, type FindGameContentsErrors, type FindGameContentsResponse, type FindGameContentsResponses, type FindGameData, type FindGameError, type FindGameErrors, type FindGameResponse, type FindGameResponses, type FindGamesData, type FindGamesError, type FindGamesErrors, type FindGamesResponse, type FindGamesResponses, type FindMemberProfileByUserIdData, type FindMemberProfileByUserIdError, type FindMemberProfileByUserIdErrors, type FindMemberProfileByUserIdResponse, type FindMemberProfileByUserIdResponses, type FindMembershipByIdData, type FindMembershipByIdError, type FindMembershipByIdErrors, type FindMembershipByIdResponse, type FindMembershipByIdResponses, type FindMembershipsData, type FindMembershipsError, type FindMembershipsErrors, type FindMembershipsResponse, type FindMembershipsResponses, type FindRosterData, type FindRosterError, type FindRosterErrors, type FindRosterResponse, type FindRosterResponses, type FindSeasonContentsData, type FindSeasonContentsError, type FindSeasonContentsErrors, type FindSeasonContentsResponse, type FindSeasonContentsResponses, type FindSeasonGamesData, type FindSeasonGamesError, type FindSeasonGamesErrors, type FindSeasonGamesResponse, type FindSeasonGamesResponses, type FindSeasonsData, type FindSeasonsError, type FindSeasonsErrors, type FindSeasonsResponse, type FindSeasonsResponses, type FindSponsorByIdData, type FindSponsorByIdError, type FindSponsorByIdErrors, type FindSponsorByIdResponse, type FindSponsorByIdResponses, type FindSponsorsData, type FindSponsorsError, type FindSponsorsErrors, type FindSponsorsResponse, type FindSponsorsResponses, type FindTeamsData, type FindTeamSeasonsData, type FindTeamSeasonsError, type FindTeamSeasonsErrors, type FindTeamSeasonsResponse, type FindTeamSeasonsResponses, type FindTeamsError, type FindTeamsErrors, type FindTeamsResponse, type FindTeamsResponses, type FindTelemetryByIdData, type FindTelemetryByIdError, type FindTelemetryByIdErrors, type FindTelemetryByIdResponse, type FindTelemetryByIdResponses, type FindUserByIdData, type FindUserByIdError, type FindUserByIdErrors, type FindUserByIdResponse, type FindUserByIdResponses, type FindUsersData, type FindUsersError, type FindUsersErrors, type FindUsersResponse, type FindUsersResponses, type ForwardAuthData, type ForwardAuthError, type ForwardAuthErrors, type ForwardAuthResponses, type GameAccountRequest, type GameAccountResponse, type GameContentsResponse, type GameResponse, type GameRostersResponse, type GetStats1Data, type GetStats1Error, type GetStats1Errors, type GetStats1Response, type GetStats1Responses, type GetStatsData, type GetStatsError, type GetStatsErrors, type GetStatsResponse, type GetStatsResponses, type GuestResponse, type HealthCheckData, type HealthCheckError, type HealthCheckErrors, type HealthCheckResponse, type HealthCheckResponses, type Image, type ImageRendition, type InboundReconcileApplyRequest, type InboundReconcileApplyResponse, type InboundReconcilePreview, type InboundReconcileRow, type JobExecution, JobExecutionCategory, type JobExecutionRelatedEntity, JobExecutionStatus, type JobPayloadField, JobPayloadFieldKind, type JobStatsDto, type JobTypeDescriptor, type JobTypesData, type JobTypesError, type JobTypesErrors, type JobTypesResponse, type JobTypesResponses, type JwtRequest, type LeaveGameData, type LeaveGameError, type LeaveGameErrors, type LeaveGameResponse, type LeaveGameResponses, type LineupSourceRequest, type LinkBoardMemberRequest, type LinkedUser, type LinkExistingTargetData, type LinkExistingTargetError, type LinkExistingTargetErrors, type LinkExistingTargetRequest, type LinkExistingTargetResponse, type LinkExistingTargetResponses, type LinkMemberData, type LinkMemberError, type LinkMemberErrors, type LinkMemberResponse, type LinkMemberResponses, type LinkRosterEntryData, type LinkRosterEntryError, type LinkRosterEntryErrors, type LinkRosterEntryRequest, type LinkRosterEntryResponse, type LinkRosterEntryResponses, type LinkUserData, type LinkUserError, type LinkUserErrors, type LinkUserRequest, type LinkUserResponse, type LinkUserResponses, type List1Data, type List1Error, type List1Errors, type List1Response, type List1Responses, type ListCohortTargetFoldersData, type ListCohortTargetFoldersError, type ListCohortTargetFoldersErrors, type ListCohortTargetFoldersResponse, type ListCohortTargetFoldersResponses, type ListCohortTargetSystemsData, type ListCohortTargetSystemsError, type ListCohortTargetSystemsErrors, type ListCohortTargetSystemsResponse, type ListCohortTargetSystemsResponses, type ListData, type ListError, type ListErrors, type ListResponse, type ListResponses, type LoginResponse, type LogoutData, type LogoutError, type LogoutErrors, type LogoutResponse, type LogoutResponses, type MarkPaidData, type MarkPaidError, type MarkPaidErrors, type MarkPaidResponse, type MarkPaidResponses, type MarkUnpaidData, type MarkUnpaidError, type MarkUnpaidErrors, type MarkUnpaidResponse, type MarkUnpaidResponses, type MemberActivateData, type MemberActivateError, type MemberActivateErrors, type MemberActivateResponse, type MemberActivateResponses, type MemberActivationRequest, type MemberProfileResponse, type MembershipApplicationRequest, type MembershipResponse, MemberType, type MoveCohortTargetData, type MoveCohortTargetError, type MoveCohortTargetErrors, type MoveCohortTargetResponse, type MoveCohortTargetResponses, type MoveCohortTargetsData, type MoveCohortTargetsError, type MoveCohortTargetsErrors, type MoveCohortTargetsResponse, type MoveCohortTargetsResponses, type MoveTargetRequest, type MyServicesData, type MyServicesError, type MyServicesErrors, type MyServicesResponse, type MyServicesResponses, type PagedModelEmail, type PagedModelEventResponse, type PagedModelJobExecution, type PagedModelUserDetailResponse, type PageMetadata, type PasswordResetRequest, type PaymentEmailsResultResponse, type PendingActivation, type PendingActivationsData, type PendingActivationsError, type PendingActivationsErrors, type PendingActivationsResponse, type PendingActivationsResponse2, type PendingActivationsResponses, PlatformType, type PreviewBulkContributionEmailData, type PreviewBulkContributionEmailError, type PreviewBulkContributionEmailErrors, type PreviewBulkContributionEmailResponse, type PreviewBulkContributionEmailResponses, type PreviewBulkEndData, type PreviewBulkEndError, type PreviewBulkEndErrors, type PreviewBulkEndResponse, type PreviewBulkEndResponses, type PreviewBulkStartData, type PreviewBulkStartError, type PreviewBulkStartErrors, type PreviewBulkStartResponse, type PreviewBulkStartResponses, type PreviewInboundReconcileData, type PreviewInboundReconcileError, type PreviewInboundReconcileErrors, type PreviewInboundReconcileResponse, type PreviewInboundReconcileResponses, type PreviewRecoveryEmailData, type PreviewRecoveryEmailError, type PreviewRecoveryEmailErrors, type PreviewRecoveryEmailResponse, type PreviewRecoveryEmailResponses, type PreviewSentEmailData, type PreviewSentEmailError, type PreviewSentEmailErrors, type PreviewSentEmailResponse, type PreviewSentEmailResponses, type QuestionRequest, type QuestionResponse, QuestionType, type ReadContributionEmailData, type ReadContributionEmailError, type ReadContributionEmailErrors, type ReadContributionEmailResponse, type ReadContributionEmailResponses, type RecoveryEmailPreviewResponse, type RedirectResponse, type RemoveMemberData, type RemoveMemberError, type RemoveMemberErrors, type RemoveMemberResponse, type RemoveMemberResponses, type RemoveRosterEntryData, type RemoveRosterEntryError, type RemoveRosterEntryErrors, type RemoveRosterEntryResponse, type RemoveRosterEntryResponses, type ReopenMembershipData, type ReopenMembershipError, type ReopenMembershipErrors, type ReopenMembershipResponse, type ReopenMembershipResponses, type RepairMissingAddsData, type RepairMissingAddsError, type RepairMissingAddsErrors, type RepairMissingAddsResponse, type RepairMissingAddsResponses, type ResendRecoveryEmailData, type ResendRecoveryEmailError, type ResendRecoveryEmailErrors, type ResendRecoveryEmailResponse, type ResendRecoveryEmailResponses, type ResendUserActivationData, type ResendUserActivationError, type ResendUserActivationErrors, type ResendUserActivationResponse, type ResendUserActivationResponses, type ResetPasswordData, type ResetPasswordError, type ResetPasswordErrors, type ResetPasswordResponse, type ResetPasswordResponses, type RestoreDeletedUserByIdData, type RestoreDeletedUserByIdError, type RestoreDeletedUserByIdErrors, type RestoreDeletedUserByIdResponse, type RestoreDeletedUserByIdResponses, type RestoreMembershipData, type RestoreMembershipError, type RestoreMembershipErrors, type RestoreMembershipResponse, type RestoreMembershipResponses, type Retry1Data, type Retry1Error, type Retry1Errors, type Retry1Response, type Retry1Responses, type RetryData, type RetryError, type RetryErrors, type RetryResponse, type RetryResponses, Role, type RosterEntryResponse, type RosterMemberResponse, type SaveAddressData, type SaveAddressError, type SaveAddressErrors, type SaveAddressResponse, type SaveAddressResponses, type SearchCohortTargetsData, type SearchCohortTargetsError, type SearchCohortTargetsErrors, type SearchCohortTargetsResponse, type SearchCohortTargetsResponses, type SeasonContentsResponse, type SeasonGameResponse, type SeasonRequest, type SeasonResponse, type SendContributionReminderBatchData, type SendContributionReminderBatchError, type SendContributionReminderBatchErrors, type SendContributionReminderBatchResponse, type SendContributionReminderBatchResponses, type SendContributionReminderData, type SendContributionReminderError, type SendContributionReminderErrors, type SendContributionReminderResponse, type SendContributionReminderResponses, type SendPaymentEmailsData, type SendPaymentEmailsError, type SendPaymentEmailsErrors, type SendPaymentEmailsRequest, type SendPaymentEmailsResponse, type SendPaymentEmailsResponses, type SentEmailPreview, type ServiceEntry, type SetGameAccountData, type SetGameAccountError, type SetGameAccountErrors, type SetGameAccountResponse, type SetGameAccountResponses, type SetPasswordData, type SetPasswordError, type SetPasswordErrors, type SetPasswordResponse, type SetPasswordResponses, type SignupAddressRequest, type SignupApplicationRequest, type SignUpData, type SignupDetailsRequest, type SignupEmailRequest, type SignUpError, type SignUpErrors, type SignupOutcomeResponse, type SignUpResponse, type SignUpResponses, type SignupSessionResponse, type SponsorResponse, type StartMembershipsData, type StartMembershipsError, type StartMembershipsErrors, type StartMembershipsResponse, type StartMembershipsResponses, type SurveyRequest, type SurveyResponse, type SwitchTargetData, type SwitchTargetError, type SwitchTargetErrors, type SwitchTargetRequest, type SwitchTargetResponse, type SwitchTargetResponses, type TargetDescriptor, TargetSystem, type TeamResponse, TeamRole, type TeamRosterResponse, type TelemetryResponse, type ToggleUserRoleData, type ToggleUserRoleError, type ToggleUserRoleErrors, type ToggleUserRoleResponse, type ToggleUserRoleResponses, TokenPurpose, type UnfieldTeamData, type UnfieldTeamError, type UnfieldTeamErrors, type UnfieldTeamResponse, type UnfieldTeamResponses, type UpdateAddressData, type UpdateAddressError, type UpdateAddressErrors, type UpdateAddressRequest, type UpdateAddressResponse, type UpdateAddressResponses, type UpdateBlogData, type UpdateBlogError, type UpdateBlogErrors, type UpdateBlogRequest, type UpdateBlogResponse, type UpdateBlogResponses, type UpdateBoardData, type UpdateBoardError, type UpdateBoardErrors, type UpdateBoardMemberRequest, type UpdateBoardRequest, type UpdateBoardResponse, type UpdateBoardResponses, type UpdateCommitteeData, type UpdateCommitteeError, type UpdateCommitteeErrors, type UpdateCommitteeRequest, type UpdateCommitteeResponse, type UpdateCommitteeResponses, type UpdateContributionPeriodData, type UpdateContributionPeriodError, type UpdateContributionPeriodErrors, type UpdateContributionPeriodRequest, type UpdateContributionPeriodResponse, type UpdateContributionPeriodResponses, type UpdateDetailsData, type UpdateDetailsError, type UpdateDetailsErrors, type UpdateDetailsResponse, type UpdateDetailsResponses, type UpdateEventData, type UpdateEventError, type UpdateEventErrors, type UpdateEventRequest, type UpdateEventResponse, type UpdateEventResponses, type UpdateEventSignUpData, type UpdateEventSignUpError, type UpdateEventSignUpErrors, type UpdateEventSignUpRequest, type UpdateEventSignUpResponse, type UpdateEventSignUpResponses, type UpdateGameData, type UpdateGameError, type UpdateGameErrors, type UpdateGameRequest, type UpdateGameResponse, type UpdateGameResponses, type UpdateMemberData, type UpdateMemberError, type UpdateMemberErrors, type UpdateMemberProfileData, type UpdateMemberProfileError, type UpdateMemberProfileErrors, type UpdateMemberProfileRequest, type UpdateMemberProfileResponse, type UpdateMemberProfileResponses, type UpdateMemberResponse, type UpdateMemberResponses, type UpdateMembershipData, type UpdateMembershipError, type UpdateMembershipErrors, type UpdateMembershipRequest, type UpdateMembershipResponse, type UpdateMembershipResponses, type UpdateRosterEntryData, type UpdateRosterEntryError, type UpdateRosterEntryErrors, type UpdateRosterEntryRequest, type UpdateRosterEntryResponse, type UpdateRosterEntryResponses, type UpdateSeasonData, type UpdateSeasonError, type UpdateSeasonErrors, type UpdateSeasonResponse, type UpdateSeasonResponses, type UpdateSponsorData, type UpdateSponsorError, type UpdateSponsorErrors, type UpdateSponsorRequest, type UpdateSponsorResponse, type UpdateSponsorResponses, type UpdateTeamData, type UpdateTeamError, type UpdateTeamErrors, type UpdateTeamRequest, type UpdateTeamResponse, type UpdateTeamResponses, type UpdateUserData, type UpdateUserError, type UpdateUserErrors, type UpdateUserRequest, type UpdateUserResponse, type UpdateUserResponses, type UploadEventBannerData, type UploadEventBannerError, type UploadEventBannerErrors, type UploadEventBannerResponse, type UploadEventBannerResponses, type UploadPublicImageData, type UploadPublicImageError, type UploadPublicImageErrors, type UploadPublicImageResponse, type UploadPublicImageResponses, type UpsertMemberProfileRequest, type UserActivateData, type UserActivateError, type UserActivateErrors, type UserActivateResponse, type UserActivateResponses, type UserActivationRequest, type UserDetailResponse, type UserSummaryResponse } from './types.gen'; diff --git a/services/frontend/src/services/api/blueshell/sdk.gen.ts b/services/frontend/src/services/api/blueshell/sdk.gen.ts index 4ac309ba2..0fc74bf7a 100644 --- a/services/frontend/src/services/api/blueshell/sdk.gen.ts +++ b/services/frontend/src/services/api/blueshell/sdk.gen.ts @@ -2,7 +2,7 @@ import { type Client, type ClientMeta, formDataBodySerializer, type Options as Options2, type RequestResult, type TDataShape } from './client'; import { client } from './client.gen'; -import type { AddMemberData, AddMemberErrors, AddMemberResponses, AddRosterEntryData, AddRosterEntryErrors, AddRosterEntryResponses, ApplyData, ApplyErrors, ApplyInboundReconcileData, ApplyInboundReconcileErrors, ApplyInboundReconcileResponses, ApplyResponses, ApproveEventData, ApproveEventErrors, ApproveEventResponses, AuthenticateData, AuthenticateErrors, AuthenticateResponses, BoardCreateMembershipData, BoardCreateMembershipErrors, BoardCreateMembershipResponses, ClearGameAccountData, ClearGameAccountErrors, ClearGameAccountResponses, CorrectEmailData, CorrectEmailErrors, CorrectEmailResponses, CreateAddressData, CreateAddressErrors, CreateAddressResponses, CreateBlogData, CreateBlogErrors, CreateBlogResponses, CreateBoardData, CreateBoardErrors, CreateBoardResponses, CreateCommitteeData, CreateCommitteeErrors, CreateCommitteeResponses, CreateContributionData, CreateContributionErrors, CreateContributionPeriodData, CreateContributionPeriodErrors, CreateContributionPeriodResponses, CreateContributionResponses, CreateEventData, CreateEventErrors, CreateEventResponses, CreateEventSignupData, CreateEventSignupErrors, CreateEventSignupResponses, CreateGameData, CreateGameErrors, CreateGameResponses, CreateMemberProfileData, CreateMemberProfileErrors, CreateMemberProfileResponses, CreateMembershipData, CreateMembershipErrors, CreateMembershipResponses, CreateSeasonData, CreateSeasonErrors, CreateSeasonResponses, CreateSponsorData, CreateSponsorErrors, CreateSponsorResponses, CreateTargetData, CreateTargetErrors, CreateTargetResponses, CreateTeamData, CreateTeamErrors, CreateTeamResponses, CreateTelemetryData, CreateTelemetryErrors, CreateTelemetryResponses, CreateUserData, CreateUserErrors, CreateUserResponses, CsrfData, CsrfErrors, CsrfResponses, DeleteAddressByIdData, DeleteAddressByIdErrors, DeleteAddressByIdResponses, DeleteBoardData, DeleteBoardErrors, DeleteBoardResponses, DeleteByIdData, DeleteByIdErrors, DeleteByIdResponses, DeleteCommitteeByIdData, DeleteCommitteeByIdErrors, DeleteCommitteeByIdResponses, DeleteContributionData, DeleteContributionErrors, DeleteContributionPeriodByIdData, DeleteContributionPeriodByIdErrors, DeleteContributionPeriodByIdResponses, DeleteContributionResponses, DeleteEventByIdData, DeleteEventByIdErrors, DeleteEventByIdResponses, DeleteEventSignupData, DeleteEventSignupErrors, DeleteEventSignupResponses, DeleteGameData, DeleteGameErrors, DeleteGameResponses, DeleteMembershipData, DeleteMembershipErrors, DeleteMembershipResponses, DeleteSeasonData, DeleteSeasonErrors, DeleteSeasonResponses, DeleteSponsorByIdData, DeleteSponsorByIdErrors, DeleteSponsorByIdResponses, DeleteTeamData, DeleteTeamErrors, DeleteTeamResponses, DeleteUserByIdData, DeleteUserByIdErrors, DeleteUserByIdResponses, DownloadEventBannerData, DownloadEventBannerErrors, DownloadEventBannerResponses, DownloadPublicFileData, DownloadPublicFileErrors, DownloadPublicFileResponses, EndMembershipData, EndMembershipErrors, EndMembershipResponses, EndMembershipsData, EndMembershipsErrors, EndMembershipsResponses, EnqueueData, EnqueueErrors, EnqueueResponses, EnterGameData, EnterGameErrors, EnterGameResponses, FieldTeamData, FieldTeamErrors, FieldTeamResponses, FindAddressByIdData, FindAddressByIdErrors, FindAddressByIdResponses, FindAllAddressesData, FindAllAddressesErrors, FindAllAddressesResponses, FindAllBoardsData, FindAllBoardsErrors, FindAllBoardsResponses, FindBlogByIdData, FindBlogByIdErrors, FindBlogByIdResponses, FindBlogsData, FindBlogsErrors, FindBlogsResponses, FindBoardByIdData, FindBoardByIdErrors, FindBoardByIdResponses, FindCohortByIdData, FindCohortByIdErrors, FindCohortByIdResponses, FindCohortsData, FindCohortsErrors, FindCohortsResponses, FindCohortSubjectByIdData, FindCohortSubjectByIdErrors, FindCohortSubjectByIdResponses, FindCohortSubjectsData, FindCohortSubjectsErrors, FindCohortSubjectsResponses, FindCommitteeByIdData, FindCommitteeByIdErrors, FindCommitteeByIdResponses, FindCommitteesByUserIdData, FindCommitteesByUserIdErrors, FindCommitteesByUserIdResponses, FindCommitteesData, FindCommitteesErrors, FindCommitteesResponses, FindContributionPeriodsData, FindContributionPeriodsErrors, FindContributionPeriodsResponses, FindContributionRemindersData, FindContributionRemindersErrors, FindContributionRemindersResponses, FindContributionsByPeriodIdData, FindContributionsByPeriodIdErrors, FindContributionsByPeriodIdResponses, FindContributionsData, FindContributionsErrors, FindContributionsResponses, FindCurrentContributionPeriodData, FindCurrentContributionPeriodErrors, FindCurrentContributionPeriodResponses, FindDeletedMembershipsData, FindDeletedMembershipsErrors, FindDeletedMembershipsResponses, FindDeletedUsersData, FindDeletedUsersErrors, FindDeletedUsersResponses, FindEventByIdData, FindEventByIdErrors, FindEventByIdResponses, FindEventsData, FindEventsErrors, FindEventSignUpsByAccessTokenData, FindEventSignUpsByAccessTokenErrors, FindEventSignUpsByAccessTokenResponses, FindEventSignUpsByEventIdData, FindEventSignUpsByEventIdErrors, FindEventSignUpsByEventIdResponses, FindEventSignUpsData, FindEventSignUpsErrors, FindEventSignUpsResponses, FindEventsResponses, FindGameAccountsData, FindGameAccountsErrors, FindGameAccountsResponses, FindGameContentsData, FindGameContentsErrors, FindGameContentsResponses, FindGameData, FindGameErrors, FindGameResponses, FindGamesData, FindGamesErrors, FindGamesResponses, FindMemberProfileByUserIdData, FindMemberProfileByUserIdErrors, FindMemberProfileByUserIdResponses, FindMembershipByIdData, FindMembershipByIdErrors, FindMembershipByIdResponses, FindMembershipsData, FindMembershipsErrors, FindMembershipsResponses, FindRosterData, FindRosterErrors, FindRosterResponses, FindSeasonContentsData, FindSeasonContentsErrors, FindSeasonContentsResponses, FindSeasonGamesData, FindSeasonGamesErrors, FindSeasonGamesResponses, FindSeasonsData, FindSeasonsErrors, FindSeasonsResponses, FindSponsorByIdData, FindSponsorByIdErrors, FindSponsorByIdResponses, FindSponsorsData, FindSponsorsErrors, FindSponsorsResponses, FindTeamsData, FindTeamSeasonsData, FindTeamSeasonsErrors, FindTeamSeasonsResponses, FindTeamsErrors, FindTeamsResponses, FindTelemetryByIdData, FindTelemetryByIdErrors, FindTelemetryByIdResponses, FindUserByIdData, FindUserByIdErrors, FindUserByIdResponses, FindUsersData, FindUsersErrors, FindUsersResponses, ForwardAuthData, ForwardAuthErrors, ForwardAuthResponses, GetStats1Data, GetStats1Errors, GetStats1Responses, GetStatsData, GetStatsErrors, GetStatsResponses, HealthCheckData, HealthCheckErrors, HealthCheckResponses, JobTypesData, JobTypesErrors, JobTypesResponses, LeaveGameData, LeaveGameErrors, LeaveGameResponses, LinkExistingTargetData, LinkExistingTargetErrors, LinkExistingTargetResponses, LinkMemberData, LinkMemberErrors, LinkMemberResponses, LinkRosterEntryData, LinkRosterEntryErrors, LinkRosterEntryResponses, LinkUserData, LinkUserErrors, LinkUserResponses, List1Data, List1Errors, List1Responses, ListCohortTargetFoldersData, ListCohortTargetFoldersErrors, ListCohortTargetFoldersResponses, ListCohortTargetSystemsData, ListCohortTargetSystemsErrors, ListCohortTargetSystemsResponses, ListData, ListErrors, ListResponses, LogoutData, LogoutErrors, LogoutResponses, MarkPaidData, MarkPaidErrors, MarkPaidResponses, MarkUnpaidData, MarkUnpaidErrors, MarkUnpaidResponses, MemberActivateData, MemberActivateErrors, MemberActivateResponses, MoveCohortTargetData, MoveCohortTargetErrors, MoveCohortTargetResponses, MoveCohortTargetsData, MoveCohortTargetsErrors, MoveCohortTargetsResponses, MyServicesData, MyServicesErrors, MyServicesResponses, PendingActivationsData, PendingActivationsErrors, PendingActivationsResponses, PreviewBulkEndData, PreviewBulkEndErrors, PreviewBulkEndResponses, PreviewBulkStartData, PreviewBulkStartErrors, PreviewBulkStartResponses, PreviewFeeCycleData, PreviewFeeCycleEmailData, PreviewFeeCycleEmailErrors, PreviewFeeCycleEmailResponses, PreviewFeeCycleErrors, PreviewFeeCycleResponses, PreviewInboundReconcileData, PreviewInboundReconcileErrors, PreviewInboundReconcileResponses, PreviewRecoveryEmailData, PreviewRecoveryEmailErrors, PreviewRecoveryEmailResponses, PreviewSentEmailData, PreviewSentEmailErrors, PreviewSentEmailResponses, RemoveMemberData, RemoveMemberErrors, RemoveMemberResponses, RemoveRosterEntryData, RemoveRosterEntryErrors, RemoveRosterEntryResponses, ReopenMembershipData, ReopenMembershipErrors, ReopenMembershipResponses, RepairMissingAddsData, RepairMissingAddsErrors, RepairMissingAddsResponses, ResendRecoveryEmailData, ResendRecoveryEmailErrors, ResendRecoveryEmailResponses, ResendUserActivationData, ResendUserActivationErrors, ResendUserActivationResponses, ResetPasswordData, ResetPasswordErrors, ResetPasswordResponses, RestoreDeletedUserByIdData, RestoreDeletedUserByIdErrors, RestoreDeletedUserByIdResponses, RestoreMembershipData, RestoreMembershipErrors, RestoreMembershipResponses, Retry1Data, Retry1Errors, Retry1Responses, RetryData, RetryErrors, RetryResponses, SaveAddressData, SaveAddressErrors, SaveAddressResponses, SearchCohortTargetsData, SearchCohortTargetsErrors, SearchCohortTargetsResponses, SendContributionReminderBatchData, SendContributionReminderBatchErrors, SendContributionReminderBatchResponses, SendContributionReminderData, SendContributionReminderErrors, SendContributionReminderResponses, SendFeeCycleData, SendFeeCycleErrors, SendFeeCycleResponses, SetGameAccountData, SetGameAccountErrors, SetGameAccountResponses, SetPasswordData, SetPasswordErrors, SetPasswordResponses, SignUpData, SignUpErrors, SignUpResponses, StartMembershipsData, StartMembershipsErrors, StartMembershipsResponses, SwitchTargetData, SwitchTargetErrors, SwitchTargetResponses, ToggleUserRoleData, ToggleUserRoleErrors, ToggleUserRoleResponses, UnfieldTeamData, UnfieldTeamErrors, UnfieldTeamResponses, UpdateAddressData, UpdateAddressErrors, UpdateAddressResponses, UpdateBlogData, UpdateBlogErrors, UpdateBlogResponses, UpdateBoardData, UpdateBoardErrors, UpdateBoardResponses, UpdateCommitteeData, UpdateCommitteeErrors, UpdateCommitteeResponses, UpdateContributionPeriodData, UpdateContributionPeriodErrors, UpdateContributionPeriodResponses, UpdateDetailsData, UpdateDetailsErrors, UpdateDetailsResponses, UpdateEventData, UpdateEventErrors, UpdateEventResponses, UpdateEventSignUpData, UpdateEventSignUpErrors, UpdateEventSignUpResponses, UpdateGameData, UpdateGameErrors, UpdateGameResponses, UpdateMemberData, UpdateMemberErrors, UpdateMemberProfileData, UpdateMemberProfileErrors, UpdateMemberProfileResponses, UpdateMemberResponses, UpdateMembershipData, UpdateMembershipErrors, UpdateMembershipResponses, UpdateRosterEntryData, UpdateRosterEntryErrors, UpdateRosterEntryResponses, UpdateSeasonData, UpdateSeasonErrors, UpdateSeasonResponses, UpdateSponsorData, UpdateSponsorErrors, UpdateSponsorResponses, UpdateTeamData, UpdateTeamErrors, UpdateTeamResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UploadEventBannerData, UploadEventBannerErrors, UploadEventBannerResponses, UploadPublicImageData, UploadPublicImageErrors, UploadPublicImageResponses, UserActivateData, UserActivateErrors, UserActivateResponses } from './types.gen'; +import type { AddMemberData, AddMemberErrors, AddMemberResponses, AddRosterEntryData, AddRosterEntryErrors, AddRosterEntryResponses, ApplyData, ApplyErrors, ApplyInboundReconcileData, ApplyInboundReconcileErrors, ApplyInboundReconcileResponses, ApplyResponses, ApproveEventData, ApproveEventErrors, ApproveEventResponses, AuthenticateData, AuthenticateErrors, AuthenticateResponses, BoardCreateMembershipData, BoardCreateMembershipErrors, BoardCreateMembershipResponses, ClearGameAccountData, ClearGameAccountErrors, ClearGameAccountResponses, CorrectEmailData, CorrectEmailErrors, CorrectEmailResponses, CreateAddressData, CreateAddressErrors, CreateAddressResponses, CreateBlogData, CreateBlogErrors, CreateBlogResponses, CreateBoardData, CreateBoardErrors, CreateBoardResponses, CreateCommitteeData, CreateCommitteeErrors, CreateCommitteeResponses, CreateContributionData, CreateContributionErrors, CreateContributionPeriodData, CreateContributionPeriodErrors, CreateContributionPeriodResponses, CreateContributionResponses, CreateEventData, CreateEventErrors, CreateEventResponses, CreateEventSignupData, CreateEventSignupErrors, CreateEventSignupResponses, CreateGameData, CreateGameErrors, CreateGameResponses, CreateMemberProfileData, CreateMemberProfileErrors, CreateMemberProfileResponses, CreateMembershipData, CreateMembershipErrors, CreateMembershipResponses, CreateSeasonData, CreateSeasonErrors, CreateSeasonResponses, CreateSponsorData, CreateSponsorErrors, CreateSponsorResponses, CreateTargetData, CreateTargetErrors, CreateTargetResponses, CreateTeamData, CreateTeamErrors, CreateTeamResponses, CreateTelemetryData, CreateTelemetryErrors, CreateTelemetryResponses, CreateUserData, CreateUserErrors, CreateUserResponses, CsrfData, CsrfErrors, CsrfResponses, DeleteAddressByIdData, DeleteAddressByIdErrors, DeleteAddressByIdResponses, DeleteBoardData, DeleteBoardErrors, DeleteBoardResponses, DeleteByIdData, DeleteByIdErrors, DeleteByIdResponses, DeleteCommitteeByIdData, DeleteCommitteeByIdErrors, DeleteCommitteeByIdResponses, DeleteContributionData, DeleteContributionErrors, DeleteContributionPeriodByIdData, DeleteContributionPeriodByIdErrors, DeleteContributionPeriodByIdResponses, DeleteContributionResponses, DeleteEventByIdData, DeleteEventByIdErrors, DeleteEventByIdResponses, DeleteEventSignupData, DeleteEventSignupErrors, DeleteEventSignupResponses, DeleteGameData, DeleteGameErrors, DeleteGameResponses, DeleteMembershipData, DeleteMembershipErrors, DeleteMembershipResponses, DeleteSeasonData, DeleteSeasonErrors, DeleteSeasonResponses, DeleteSponsorByIdData, DeleteSponsorByIdErrors, DeleteSponsorByIdResponses, DeleteTeamData, DeleteTeamErrors, DeleteTeamResponses, DeleteUserByIdData, DeleteUserByIdErrors, DeleteUserByIdResponses, DownloadEventBannerData, DownloadEventBannerErrors, DownloadEventBannerResponses, DownloadPublicFileData, DownloadPublicFileErrors, DownloadPublicFileResponses, EndMembershipData, EndMembershipErrors, EndMembershipResponses, EndMembershipsData, EndMembershipsErrors, EndMembershipsResponses, EnqueueData, EnqueueErrors, EnqueueResponses, EnterGameData, EnterGameErrors, EnterGameResponses, FieldTeamData, FieldTeamErrors, FieldTeamResponses, FindAddressByIdData, FindAddressByIdErrors, FindAddressByIdResponses, FindAllAddressesData, FindAllAddressesErrors, FindAllAddressesResponses, FindAllBoardsData, FindAllBoardsErrors, FindAllBoardsResponses, FindBlogByIdData, FindBlogByIdErrors, FindBlogByIdResponses, FindBlogsData, FindBlogsErrors, FindBlogsResponses, FindBoardByIdData, FindBoardByIdErrors, FindBoardByIdResponses, FindCohortByIdData, FindCohortByIdErrors, FindCohortByIdResponses, FindCohortsData, FindCohortsErrors, FindCohortsResponses, FindCohortSubjectByIdData, FindCohortSubjectByIdErrors, FindCohortSubjectByIdResponses, FindCohortSubjectsData, FindCohortSubjectsErrors, FindCohortSubjectsResponses, FindCommitteeByIdData, FindCommitteeByIdErrors, FindCommitteeByIdResponses, FindCommitteesByUserIdData, FindCommitteesByUserIdErrors, FindCommitteesByUserIdResponses, FindCommitteesData, FindCommitteesErrors, FindCommitteesResponses, FindContributionPeriodsData, FindContributionPeriodsErrors, FindContributionPeriodsResponses, FindContributionRemindersData, FindContributionRemindersErrors, FindContributionRemindersResponses, FindContributionsByPeriodIdData, FindContributionsByPeriodIdErrors, FindContributionsByPeriodIdResponses, FindContributionsData, FindContributionsErrors, FindContributionsResponses, FindCurrentContributionPeriodData, FindCurrentContributionPeriodErrors, FindCurrentContributionPeriodResponses, FindDeletedMembershipsData, FindDeletedMembershipsErrors, FindDeletedMembershipsResponses, FindDeletedUsersData, FindDeletedUsersErrors, FindDeletedUsersResponses, FindEventByIdData, FindEventByIdErrors, FindEventByIdResponses, FindEventsData, FindEventsErrors, FindEventSignUpsByAccessTokenData, FindEventSignUpsByAccessTokenErrors, FindEventSignUpsByAccessTokenResponses, FindEventSignUpsByEventIdData, FindEventSignUpsByEventIdErrors, FindEventSignUpsByEventIdResponses, FindEventSignUpsData, FindEventSignUpsErrors, FindEventSignUpsResponses, FindEventsResponses, FindGameAccountsData, FindGameAccountsErrors, FindGameAccountsResponses, FindGameContentsData, FindGameContentsErrors, FindGameContentsResponses, FindGameData, FindGameErrors, FindGameResponses, FindGamesData, FindGamesErrors, FindGamesResponses, FindMemberProfileByUserIdData, FindMemberProfileByUserIdErrors, FindMemberProfileByUserIdResponses, FindMembershipByIdData, FindMembershipByIdErrors, FindMembershipByIdResponses, FindMembershipsData, FindMembershipsErrors, FindMembershipsResponses, FindRosterData, FindRosterErrors, FindRosterResponses, FindSeasonContentsData, FindSeasonContentsErrors, FindSeasonContentsResponses, FindSeasonGamesData, FindSeasonGamesErrors, FindSeasonGamesResponses, FindSeasonsData, FindSeasonsErrors, FindSeasonsResponses, FindSponsorByIdData, FindSponsorByIdErrors, FindSponsorByIdResponses, FindSponsorsData, FindSponsorsErrors, FindSponsorsResponses, FindTeamsData, FindTeamSeasonsData, FindTeamSeasonsErrors, FindTeamSeasonsResponses, FindTeamsErrors, FindTeamsResponses, FindTelemetryByIdData, FindTelemetryByIdErrors, FindTelemetryByIdResponses, FindUserByIdData, FindUserByIdErrors, FindUserByIdResponses, FindUsersData, FindUsersErrors, FindUsersResponses, ForwardAuthData, ForwardAuthErrors, ForwardAuthResponses, GetStats1Data, GetStats1Errors, GetStats1Responses, GetStatsData, GetStatsErrors, GetStatsResponses, HealthCheckData, HealthCheckErrors, HealthCheckResponses, JobTypesData, JobTypesErrors, JobTypesResponses, LeaveGameData, LeaveGameErrors, LeaveGameResponses, LinkExistingTargetData, LinkExistingTargetErrors, LinkExistingTargetResponses, LinkMemberData, LinkMemberErrors, LinkMemberResponses, LinkRosterEntryData, LinkRosterEntryErrors, LinkRosterEntryResponses, LinkUserData, LinkUserErrors, LinkUserResponses, List1Data, List1Errors, List1Responses, ListCohortTargetFoldersData, ListCohortTargetFoldersErrors, ListCohortTargetFoldersResponses, ListCohortTargetSystemsData, ListCohortTargetSystemsErrors, ListCohortTargetSystemsResponses, ListData, ListErrors, ListResponses, LogoutData, LogoutErrors, LogoutResponses, MarkPaidData, MarkPaidErrors, MarkPaidResponses, MarkUnpaidData, MarkUnpaidErrors, MarkUnpaidResponses, MemberActivateData, MemberActivateErrors, MemberActivateResponses, MoveCohortTargetData, MoveCohortTargetErrors, MoveCohortTargetResponses, MoveCohortTargetsData, MoveCohortTargetsErrors, MoveCohortTargetsResponses, MyServicesData, MyServicesErrors, MyServicesResponses, PendingActivationsData, PendingActivationsErrors, PendingActivationsResponses, PreviewBulkContributionEmailData, PreviewBulkContributionEmailErrors, PreviewBulkContributionEmailResponses, PreviewBulkEndData, PreviewBulkEndErrors, PreviewBulkEndResponses, PreviewBulkStartData, PreviewBulkStartErrors, PreviewBulkStartResponses, PreviewInboundReconcileData, PreviewInboundReconcileErrors, PreviewInboundReconcileResponses, PreviewRecoveryEmailData, PreviewRecoveryEmailErrors, PreviewRecoveryEmailResponses, PreviewSentEmailData, PreviewSentEmailErrors, PreviewSentEmailResponses, ReadContributionEmailData, ReadContributionEmailErrors, ReadContributionEmailResponses, RemoveMemberData, RemoveMemberErrors, RemoveMemberResponses, RemoveRosterEntryData, RemoveRosterEntryErrors, RemoveRosterEntryResponses, ReopenMembershipData, ReopenMembershipErrors, ReopenMembershipResponses, RepairMissingAddsData, RepairMissingAddsErrors, RepairMissingAddsResponses, ResendRecoveryEmailData, ResendRecoveryEmailErrors, ResendRecoveryEmailResponses, ResendUserActivationData, ResendUserActivationErrors, ResendUserActivationResponses, ResetPasswordData, ResetPasswordErrors, ResetPasswordResponses, RestoreDeletedUserByIdData, RestoreDeletedUserByIdErrors, RestoreDeletedUserByIdResponses, RestoreMembershipData, RestoreMembershipErrors, RestoreMembershipResponses, Retry1Data, Retry1Errors, Retry1Responses, RetryData, RetryErrors, RetryResponses, SaveAddressData, SaveAddressErrors, SaveAddressResponses, SearchCohortTargetsData, SearchCohortTargetsErrors, SearchCohortTargetsResponses, SendContributionReminderBatchData, SendContributionReminderBatchErrors, SendContributionReminderBatchResponses, SendContributionReminderData, SendContributionReminderErrors, SendContributionReminderResponses, SendPaymentEmailsData, SendPaymentEmailsErrors, SendPaymentEmailsResponses, SetGameAccountData, SetGameAccountErrors, SetGameAccountResponses, SetPasswordData, SetPasswordErrors, SetPasswordResponses, SignUpData, SignUpErrors, SignUpResponses, StartMembershipsData, StartMembershipsErrors, StartMembershipsResponses, SwitchTargetData, SwitchTargetErrors, SwitchTargetResponses, ToggleUserRoleData, ToggleUserRoleErrors, ToggleUserRoleResponses, UnfieldTeamData, UnfieldTeamErrors, UnfieldTeamResponses, UpdateAddressData, UpdateAddressErrors, UpdateAddressResponses, UpdateBlogData, UpdateBlogErrors, UpdateBlogResponses, UpdateBoardData, UpdateBoardErrors, UpdateBoardResponses, UpdateCommitteeData, UpdateCommitteeErrors, UpdateCommitteeResponses, UpdateContributionPeriodData, UpdateContributionPeriodErrors, UpdateContributionPeriodResponses, UpdateDetailsData, UpdateDetailsErrors, UpdateDetailsResponses, UpdateEventData, UpdateEventErrors, UpdateEventResponses, UpdateEventSignUpData, UpdateEventSignUpErrors, UpdateEventSignUpResponses, UpdateGameData, UpdateGameErrors, UpdateGameResponses, UpdateMemberData, UpdateMemberErrors, UpdateMemberProfileData, UpdateMemberProfileErrors, UpdateMemberProfileResponses, UpdateMemberResponses, UpdateMembershipData, UpdateMembershipErrors, UpdateMembershipResponses, UpdateRosterEntryData, UpdateRosterEntryErrors, UpdateRosterEntryResponses, UpdateSeasonData, UpdateSeasonErrors, UpdateSeasonResponses, UpdateSponsorData, UpdateSponsorErrors, UpdateSponsorResponses, UpdateTeamData, UpdateTeamErrors, UpdateTeamResponses, UpdateUserData, UpdateUserErrors, UpdateUserResponses, UploadEventBannerData, UploadEventBannerErrors, UploadEventBannerResponses, UploadPublicImageData, UploadPublicImageErrors, UploadPublicImageResponses, UserActivateData, UserActivateErrors, UserActivateResponses } from './types.gen'; export type Options = Options2 & { /** @@ -288,9 +288,15 @@ export const createContribution = (options } }); -export const markPaid = (options: Options): RequestResult => (options.client ?? client).post({ +export const readContributionEmail = (options: Options): RequestResult => (options.client ?? client).get({ responseType: 'json', - url: '/contributions/bulk/mark-paid', + url: '/contributions/bulk/email/message', + ...options +}); + +export const previewBulkContributionEmail = (options: Options): RequestResult => (options.client ?? client).post({ + responseType: 'json', + url: '/contributions/bulk/email/preview', ...options, headers: { 'Content-Type': 'application/json', @@ -298,9 +304,9 @@ export const markPaid = (options: Options< } }); -export const markUnpaid = (options: Options): RequestResult => (options.client ?? client).post({ +export const sendPaymentEmails = (options: Options): RequestResult => (options.client ?? client).post({ responseType: 'json', - url: '/contributions/bulk/mark-unpaid', + url: '/contributions/bulk/email/send', ...options, headers: { 'Content-Type': 'application/json', @@ -308,21 +314,19 @@ export const markUnpaid = (options: Option } }); -export const previewFeeCycle = (options: Options): RequestResult => (options.client ?? client).get({ - responseType: 'json', - url: '/contributions/fee-cycle', - ...options -}); - -export const previewFeeCycleEmail = (options: Options): RequestResult => (options.client ?? client).get({ +export const markPaid = (options: Options): RequestResult => (options.client ?? client).post({ responseType: 'json', - url: '/contributions/fee-cycle/email-preview', - ...options + url: '/contributions/bulk/mark-paid', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } }); -export const sendFeeCycle = (options: Options): RequestResult => (options.client ?? client).post({ +export const markUnpaid = (options: Options): RequestResult => (options.client ?? client).post({ responseType: 'json', - url: '/contributions/fee-cycle/send', + url: '/contributions/bulk/mark-unpaid', ...options, headers: { 'Content-Type': 'application/json', diff --git a/services/frontend/src/services/api/blueshell/types.gen.ts b/services/frontend/src/services/api/blueshell/types.gen.ts index 64b31bdd2..f9a644d83 100644 --- a/services/frontend/src/services/api/blueshell/types.gen.ts +++ b/services/frontend/src/services/api/blueshell/types.gen.ts @@ -241,6 +241,49 @@ export type BulkActionResult = { skipped: number; }; +export type BulkContributionEmailPreviewRequest = { + contributionPeriodId: number | null; + userIds: Array; +}; + +export type BulkContributionEmailPreviewResponse = { + contributionPeriodId: number; + rows: Array; + /** + * Selected ids that resolve to nobody. No row is drawn, and the send refuses them. + */ + unknownUserIds: Array; +}; + +export type BulkContributionEmailRowResponse = { + /** + * Follows from the fee type and the period. Never typed. + */ + amount?: number | null; + /** + * The email this member gets unless the treasurer switches it. + */ + defaultKind: ContributionEmailKind; + disposition: BulkRowDisposition; + /** + * Absent only for honorary members, who owe nothing. + */ + feeType?: BulkFeeType | null; + lastNotifiedOn?: string | null; + lastRemindedOn?: string | null; + /** + * Start of the membership every decision on this row was judged against. + */ + memberSince?: string | null; + memberType: MemberType; + name: string; + /** + * Why this member is warned about or not written to. + */ + reason?: BulkRowReason | null; + userId: number; +}; + export enum BulkFeeType { FULL_YEAR_FEE = 'FULL_YEAR_FEE', HALF_YEAR_FEE = 'HALF_YEAR_FEE', @@ -299,6 +342,7 @@ export enum BulkRowReason { HONORARY = 'HONORARY', INCASSO_MISMATCH = 'INCASSO_MISMATCH', NO_ACTIVE_MEMBERSHIP = 'NO_ACTIVE_MEMBERSHIP', + NOT_MEMBER_IN_PERIOD = 'NOT_MEMBER_IN_PERIOD', STARTED_TODAY = 'STARTED_TODAY', NO_EMAIL = 'NO_EMAIL', DELETED = 'DELETED', @@ -313,7 +357,6 @@ export enum BulkRowReason { */ export type BulkRowVocabulary = { disposition: BulkRowDisposition; - feeCycleGroup: FeeCycleGroup; feeType: BulkFeeType; reason: BulkRowReason; }; @@ -498,6 +541,20 @@ export enum ContactSystem { BREVO = 'BREVO' } +export enum ContributionEmailKind { + REMINDER = 'REMINDER', + INCASSO_NOTIFICATION = 'INCASSO_NOTIFICATION' +} + +export type ContributionEmailMessageResponse = { + feeType: BulkFeeType; + html: string; + kind: ContributionEmailKind; + recipientEmail: string; + recipientName: string; + subject: string; +}; + export type ContributionPeriodResponse = { alumniFee: number; contactListId?: number | null; @@ -873,71 +930,6 @@ export type FailedTargetMove = { message: string; }; -export type FeeCycleEmailPreviewResponse = { - /** - * The fee type the email states, and the reason it gives. - */ - feeType: BulkFeeType; - /** - * Which statement this member receives, decided by their direct-debit flag. - */ - group: FeeCycleGroup; - html: string; - recipientEmail: string; - recipientName: string; - subject: string; -}; - -export enum FeeCycleGroup { - DIRECT_DEBIT = 'DIRECT_DEBIT', - TRANSFER = 'TRANSFER' -} - -export type FeeCyclePreviewResponse = { - contributionPeriodId: number; - rows: Array; -}; - -export type FeeCycleResultResponse = { - /** - * Members in the cycle who were not written to. - */ - excluded: number; - paymentRequestsQueued: number; - preNotificationsQueued: number; -}; - -export type FeeCycleRowResponse = { - /** - * Follows from the fee type and the period. Never typed. - */ - amount?: number | null; - disposition: BulkRowDisposition; - /** - * Absent only for honorary members, who owe nothing. - */ - feeType?: BulkFeeType | null; - /** - * Decided by the member's direct-debit flag, not by the operator. - */ - group: FeeCycleGroup; - /** - * When this member was last asked for this period, on this side of the partition. - */ - lastAskedOn?: string | null; - /** - * Start of the membership every decision on this row was judged against. - */ - memberSince?: string | null; - memberType: MemberType; - name: string; - /** - * Why this member is not written to. Absent on an included row. - */ - reason?: BulkRowReason | null; - userId: number; -}; - /** * Field a team in a game in a season, with or without the line-up it last had */ @@ -1439,6 +1431,15 @@ export type PasswordResetRequest = { token: string; }; +export type PaymentEmailsResultResponse = { + incassoNotificationsSent: number; + /** + * Members in the selection who were not written to. + */ + notWrittenTo: number; + remindersSent: number; +}; + /** * The activation email an account that has not been activated takes. */ @@ -1650,22 +1651,33 @@ export type SeasonResponse = { startDate: string; }; -export type SendFeeCycleRequest = { +export type SendPaymentEmailsRequest = { contributionPeriodId: number | null; /** - * The date the direct-debit group is told the money will be taken. + * Required exactly when somebody here is getting a pre-notification. */ - debitDate: string | null; + debitDate?: string | null; /** - * Fee type per member, where the treasurer changed it from the one that applies. + * Fee type per member, where the treasurer changed it. */ feeTypeOverrides: { [key: string]: BulkFeeType; }; /** - * The date the transfer group is asked to have paid by. + * Warned members this request overrules. Every one of them must be in the selection and must be somebody the send writes to. + */ + forciblyIncludedUserIds: Array; + /** + * Members moved off the email their direct-debit flag chose. */ - paymentDueDate: string | null; + kindOverrides: { + [key: string]: ContributionEmailKind; + }; + /** + * Required exactly when somebody here is getting a payment request. + */ + paymentDueDate?: string | null; + userIds: Array; }; export type SentEmailPreview = { @@ -3741,14 +3753,20 @@ export type CreateContributionResponses = { export type CreateContributionResponse = CreateContributionResponses[keyof CreateContributionResponses]; -export type MarkPaidData = { - body: BulkMarkPaidRequest; +export type ReadContributionEmailData = { + body?: never; path?: never; - query?: never; - url: '/contributions/bulk/mark-paid'; + query: { + kind: ContributionEmailKind; + contributionPeriodId: number; + userId: number; + date: string; + feeType?: BulkFeeType; + }; + url: '/contributions/bulk/email/message'; }; -export type MarkPaidErrors = { +export type ReadContributionEmailErrors = { /** * Validation error */ @@ -3771,25 +3789,25 @@ export type MarkPaidErrors = { 500: ApiError; }; -export type MarkPaidError = MarkPaidErrors[keyof MarkPaidErrors]; +export type ReadContributionEmailError = ReadContributionEmailErrors[keyof ReadContributionEmailErrors]; -export type MarkPaidResponses = { +export type ReadContributionEmailResponses = { /** * OK */ - 200: BulkActionResult; + 200: ContributionEmailMessageResponse; }; -export type MarkPaidResponse = MarkPaidResponses[keyof MarkPaidResponses]; +export type ReadContributionEmailResponse = ReadContributionEmailResponses[keyof ReadContributionEmailResponses]; -export type MarkUnpaidData = { - body: BulkMarkUnpaidRequest; +export type PreviewBulkContributionEmailData = { + body: BulkContributionEmailPreviewRequest; path?: never; query?: never; - url: '/contributions/bulk/mark-unpaid'; + url: '/contributions/bulk/email/preview'; }; -export type MarkUnpaidErrors = { +export type PreviewBulkContributionEmailErrors = { /** * Validation error */ @@ -3812,27 +3830,25 @@ export type MarkUnpaidErrors = { 500: ApiError; }; -export type MarkUnpaidError = MarkUnpaidErrors[keyof MarkUnpaidErrors]; +export type PreviewBulkContributionEmailError = PreviewBulkContributionEmailErrors[keyof PreviewBulkContributionEmailErrors]; -export type MarkUnpaidResponses = { +export type PreviewBulkContributionEmailResponses = { /** * OK */ - 200: BulkActionResult; + 200: BulkContributionEmailPreviewResponse; }; -export type MarkUnpaidResponse = MarkUnpaidResponses[keyof MarkUnpaidResponses]; +export type PreviewBulkContributionEmailResponse = PreviewBulkContributionEmailResponses[keyof PreviewBulkContributionEmailResponses]; -export type PreviewFeeCycleData = { - body?: never; +export type SendPaymentEmailsData = { + body: SendPaymentEmailsRequest; path?: never; - query: { - contributionPeriodId: number; - }; - url: '/contributions/fee-cycle'; + query?: never; + url: '/contributions/bulk/email/send'; }; -export type PreviewFeeCycleErrors = { +export type SendPaymentEmailsErrors = { /** * Validation error */ @@ -3855,31 +3871,25 @@ export type PreviewFeeCycleErrors = { 500: ApiError; }; -export type PreviewFeeCycleError = PreviewFeeCycleErrors[keyof PreviewFeeCycleErrors]; +export type SendPaymentEmailsError = SendPaymentEmailsErrors[keyof SendPaymentEmailsErrors]; -export type PreviewFeeCycleResponses = { +export type SendPaymentEmailsResponses = { /** * OK */ - 200: FeeCyclePreviewResponse; + 200: PaymentEmailsResultResponse; }; -export type PreviewFeeCycleResponse = PreviewFeeCycleResponses[keyof PreviewFeeCycleResponses]; +export type SendPaymentEmailsResponse = SendPaymentEmailsResponses[keyof SendPaymentEmailsResponses]; -export type PreviewFeeCycleEmailData = { - body?: never; +export type MarkPaidData = { + body: BulkMarkPaidRequest; path?: never; - query: { - contributionPeriodId: number; - userId: number; - paymentDueDate: string; - debitDate: string; - feeType?: BulkFeeType; - }; - url: '/contributions/fee-cycle/email-preview'; + query?: never; + url: '/contributions/bulk/mark-paid'; }; -export type PreviewFeeCycleEmailErrors = { +export type MarkPaidErrors = { /** * Validation error */ @@ -3902,25 +3912,25 @@ export type PreviewFeeCycleEmailErrors = { 500: ApiError; }; -export type PreviewFeeCycleEmailError = PreviewFeeCycleEmailErrors[keyof PreviewFeeCycleEmailErrors]; +export type MarkPaidError = MarkPaidErrors[keyof MarkPaidErrors]; -export type PreviewFeeCycleEmailResponses = { +export type MarkPaidResponses = { /** * OK */ - 200: FeeCycleEmailPreviewResponse; + 200: BulkActionResult; }; -export type PreviewFeeCycleEmailResponse = PreviewFeeCycleEmailResponses[keyof PreviewFeeCycleEmailResponses]; +export type MarkPaidResponse = MarkPaidResponses[keyof MarkPaidResponses]; -export type SendFeeCycleData = { - body: SendFeeCycleRequest; +export type MarkUnpaidData = { + body: BulkMarkUnpaidRequest; path?: never; query?: never; - url: '/contributions/fee-cycle/send'; + url: '/contributions/bulk/mark-unpaid'; }; -export type SendFeeCycleErrors = { +export type MarkUnpaidErrors = { /** * Validation error */ @@ -3943,16 +3953,16 @@ export type SendFeeCycleErrors = { 500: ApiError; }; -export type SendFeeCycleError = SendFeeCycleErrors[keyof SendFeeCycleErrors]; +export type MarkUnpaidError = MarkUnpaidErrors[keyof MarkUnpaidErrors]; -export type SendFeeCycleResponses = { +export type MarkUnpaidResponses = { /** * OK */ - 200: FeeCycleResultResponse; + 200: BulkActionResult; }; -export type SendFeeCycleResponse = SendFeeCycleResponses[keyof SendFeeCycleResponses]; +export type MarkUnpaidResponse = MarkUnpaidResponses[keyof MarkUnpaidResponses]; export type CsrfData = { body?: never; diff --git a/services/frontend/src/styles/payment-email.scss b/services/frontend/src/styles/payment-email.scss new file mode 100644 index 000000000..623e02d72 --- /dev/null +++ b/services/frontend/src/styles/payment-email.scss @@ -0,0 +1,9 @@ +// The wizard's step tables share a header that stays put while the body under it scrolls. +@mixin sticky-table-header { + .payment-email-table :deep(thead th) { + position: sticky; + top: 0; + z-index: 1; + background-color: rgb(var(--v-theme-surface)); + } +} diff --git a/services/frontend/src/utils/bulkDisposition.ts b/services/frontend/src/utils/bulkDisposition.ts index 9047dfcb1..e12d7c42f 100644 --- a/services/frontend/src/utils/bulkDisposition.ts +++ b/services/frontend/src/utils/bulkDisposition.ts @@ -4,7 +4,7 @@ import type {BulkDisposition, BulkRow, BulkRowReason} from "@/utils/bulkRow" /** * Pure display helpers for bulk-action preview rows, lifted verbatim out of the old * BulkActionConfirmDialog monolith. Shared by every per-action dialog via the scaffold. - * See docs/flows/fee-cycle/README.md and docs/flows/bulk-contribution-marking/README.md. + * See docs/flows/payment-emails/README.md and docs/flows/bulk-contribution-marking/README.md. */ // Re-exported for existing importers that pulled these off bulkDisposition. @@ -46,11 +46,12 @@ export function dispositionColor(disposition: BulkDisposition): string { } const REASON_LABELS: Record = { - INCASSO_MISMATCH: "Not marked for incasso", - ALREADY_PAID: "Already paid", - HONORARY: "Honorary (no contribution needed)", + INCASSO_MISMATCH: "No direct-debit mandate", + ALREADY_PAID: "Already paid this contribution", + HONORARY: "Owes no contribution", NOT_PAID: "Not paid", NO_ACTIVE_MEMBERSHIP: "No active membership", + NOT_MEMBER_IN_PERIOD: "Not a member during this period", STARTED_TODAY: "Started today", NO_EMAIL: "No email address on file", DELETED: "Account deleted", diff --git a/services/frontend/src/utils/bulkRejection.ts b/services/frontend/src/utils/bulkRejection.ts index 5f577967e..73b3ba59f 100644 --- a/services/frontend/src/utils/bulkRejection.ts +++ b/services/frontend/src/utils/bulkRejection.ts @@ -1,7 +1,7 @@ import type {ApiError, FieldValidationError} from "@/services/api" /** - * Codes a bulk endpoint returns when it refuses a selection. The api never applies a + * Codes a bulk endpoint returns when it refuses a request. The api never applies a * selection partly, so a refusal means nothing was written and the offending rows are * named in `values`. */ @@ -12,6 +12,16 @@ export const BulkRejectionCode = { deletedUsers: "DeletedUserIds", /** Honorary members, who owe no contribution. */ honoraryUsers: "HonoraryUserIds", + /** The same user named twice, which means the client has lost count of its own rows. */ + duplicateUsers: "DuplicateUserIds", + /** A user ticked back in that the action still does not write to. */ + nonRecipientForced: "NonRecipientForcedUserIds", + /** A user ticked back in that the selection does not name at all. */ + unknownForced: "UnknownForcedUserIds", + /** A date somebody in the batch is getting an email about, left out of the request. */ + dateRequired: "DateRequired", + /** A date before the contribution period starts, or too long after it ends. */ + dateOutsidePeriod: "DateOutsideContributionPeriod", /** The chosen contribution period has gone. */ unknownPeriod: "UnknownContributionPeriodId", /** External targets the system no longer has; the catalogue is stale. */ @@ -38,6 +48,8 @@ export interface BulkRejectionReason { export interface BulkRejection { reasons: BulkRejectionReason[] + /** 409 means the client's table is stale; 400 that a field of the request is wrong. */ + status: number /** Every user id the api named, whatever the reason. */ namedUserIds: number[] /** Every non-numeric identifier the api named, whatever the reason. */ @@ -55,20 +67,41 @@ const RELOAD_CODES: readonly string[] = [ BulkRejectionCode.unknownTargets, ] +/** + * Sentences composed here from the code rather than taken from the api's `message`, per + * ADR-026. The older codes keep the message the api composes for them. + */ +const COMPOSED_MESSAGES: Record = { + [BulkRejectionCode.duplicateUsers]: "The selection names the same member more than once.", + [BulkRejectionCode.nonRecipientForced]: + "Some of the members ticked back in are ones this send does not write to.", + [BulkRejectionCode.unknownForced]: + "Some of the members ticked back in are not in the selection.", + [BulkRejectionCode.dateRequired]: + "This date is required: somebody in this batch gets an email that states it.", + [BulkRejectionCode.dateOutsidePeriod]: + "This date must fall within the contribution period, or shortly after it ends.", +} + +// Both advices answer in the same `errors[]` shape: 409 for a stale selection, 400 for a +// field a rule refused. One parser covers both. +const REFUSAL_STATUSES: readonly number[] = [400, 409] + function isApiError(value: unknown): value is ApiError { return typeof value === "object" && value !== null && "errors" in value } /** * Reads a refusal out of a generated-client result. Returns null for anything that is - * not a refused selection, so a caller can fall through to its ordinary error handling + * not a refused request, so a caller can fall through to its ordinary error handling * rather than reporting a misleading reason. */ export function parseBulkRejection(result: { error?: unknown response?: {status?: number} }): BulkRejection | null { - if (result.response?.status !== 409) return null + const status = result.response?.status + if (status == null || !REFUSAL_STATUSES.includes(status)) return null if (!isApiError(result.error)) return null const errors: FieldValidationError[] = result.error.errors ?? [] @@ -77,7 +110,7 @@ export function parseBulkRejection(result: { .map((entry) => ({ code: entry.code, field: entry.field ?? "", - message: entry.message ?? "", + message: COMPOSED_MESSAGES[entry.code] ?? entry.message ?? "", userIds: (entry.values ?? []).filter((id): id is number => typeof id === "number"), refs: (entry.refs ?? []).filter((ref): ref is string => typeof ref === "string"), })) @@ -86,6 +119,7 @@ export function parseBulkRejection(result: { return { reasons, + status, namedUserIds: [...new Set(reasons.flatMap((reason) => reason.userIds))], namedRefs: [...new Set(reasons.flatMap((reason) => reason.refs))], requiresReload: reasons.some((reason) => RELOAD_CODES.includes(reason.code)), diff --git a/services/frontend/src/utils/bulkRow.ts b/services/frontend/src/utils/bulkRow.ts index ef8ca4e53..12bac4790 100644 --- a/services/frontend/src/utils/bulkRow.ts +++ b/services/frontend/src/utils/bulkRow.ts @@ -1,5 +1,5 @@ import type {MemberType} from "@/services/api" -import {BulkFeeType, BulkRowDisposition, BulkRowReason, FeeCycleGroup} from "@/services/api" +import {BulkFeeType, BulkRowDisposition, BulkRowReason, ContributionEmailKind} from "@/services/api" /** * Local FE model for bulk-action rows. Rows are computed client-side (bulkCompute.ts), @@ -12,7 +12,7 @@ import {BulkFeeType, BulkRowDisposition, BulkRowReason, FeeCycleGroup} from "@/s // are re-exported for callers that need the values, e.g. to build a select. export type BulkDisposition = `${BulkRowDisposition}` export type BulkRowReasonCode = `${BulkRowReason}` -export {BulkFeeType, BulkRowDisposition, BulkRowReason, FeeCycleGroup} +export {BulkFeeType, BulkRowDisposition, BulkRowReason, ContributionEmailKind} /** Summary counts for the bulk-action confirmation dialog's summary bar. */ export interface BulkActionCounts { @@ -43,11 +43,9 @@ export interface BulkRow { memberSince?: string | null amount?: number | null recommendedFeeType?: BulkFeeType - /** Email actions only: when the last reminder/incasso was sent, if known. */ - lastSentOn?: string - /** - * Fee cycle only: which side of the direct-debit partition this member is on. Set by the - * api from the member's own flag, so the dialog shows it rather than offering it. - */ - group?: FeeCycleGroup + /** Payment emails only: which of the two this member's direct-debit flag chose. */ + defaultKind?: ContributionEmailKind + // Per kind, so a member sent a payment request does not read as having been pre-notified. + lastRemindedOn?: string + lastNotifiedOn?: string } diff --git a/services/frontend/src/utils/contributionEmail.ts b/services/frontend/src/utils/contributionEmail.ts new file mode 100644 index 000000000..90861af98 --- /dev/null +++ b/services/frontend/src/utils/contributionEmail.ts @@ -0,0 +1,325 @@ +import {DateTime} from "luxon" +import { + ContributionEmailKind, + type BulkContributionEmailRowResponse, + type ContributionPeriodResponse, +} from "@/services/api" +import {formatBulkDate, reasonLabel} from "@/utils/bulkDisposition" +import {BulkFeeType, type BulkRow} from "@/utils/bulkRow" +import {feeTypeLabels} from "@/utils/feePreview" + +/** + * The payment-email rows, as the wizard reads them. + * + * Decided by the api rather than the browser: whether a member is warned about turns on + * facts this page does not hold, and the send re-reads the same answer. + */ + +export function toBulkRow(row: BulkContributionEmailRowResponse): BulkRow { + return { + userId: row.userId, + name: row.name, + disposition: row.disposition, + reason: row.reason ?? undefined, + memberType: row.memberType, + memberSince: row.memberSince ?? undefined, + amount: row.amount ?? null, + recommendedFeeType: row.feeType ?? undefined, + defaultKind: row.defaultKind, + lastRemindedOn: row.lastRemindedOn ?? undefined, + lastNotifiedOn: row.lastNotifiedOn ?? undefined, + } +} + +export function toBulkRows(rows: BulkContributionEmailRowResponse[]): BulkRow[] { + return rows.map(toBulkRow) +} + +export const contributionEmailLabels: Record = { + [ContributionEmailKind.REMINDER]: "Contribution reminder", + [ContributionEmailKind.INCASSO_NOTIFICATION]: "Incasso notification", +} + +export const contributionEmailItems: Array<{title: string; value: ContributionEmailKind}> = + Object.values(ContributionEmailKind).map((value) => ({title: contributionEmailLabels[value], value})) + +/** Which email a row is set to: the flag's choice unless the treasurer switched it. */ +export function kindFor(row: BulkRow, chosen: Record): ContributionEmailKind { + return chosen[row.userId] ?? row.defaultKind ?? ContributionEmailKind.REMINDER +} + +export function isSwitched(row: BulkRow, chosen: Record): boolean { + return !!row.defaultKind && kindFor(row, chosen) !== row.defaultKind +} + +/** A fee the treasurer moved off the one the row's membership works out to. */ +export function isReCharged(row: BulkRow, fees: Record): boolean { + const chosen = fees[row.userId] + return !!chosen && chosen !== row.recommendedFeeType +} + +/** Why switching this row is worth flagging: the flag says the other thing. */ +export function switchedNote(row: BulkRow): string { + return row.defaultKind === ContributionEmailKind.INCASSO_NOTIFICATION + ? "Pays by direct debit, so a reminder asks for money that is taken anyway" + : "No direct-debit mandate, so nothing will be taken on the debit date" +} + +/** + * What is happening to this member, both halves of it. A line that only says the flag + * disagrees leaves the reader to work out which way round it is. + */ +export function switchedDescription( + row: BulkRow, + chosen: Record, +): string { + const getting = contributionEmailLabels[kindFor(row, chosen)].toLowerCase() + return row.defaultKind === ContributionEmailKind.INCASSO_NOTIFICATION + ? `${row.name} pays by direct debit but is getting a ${getting}` + : `${row.name} has no direct-debit mandate but is getting an ${getting}` +} + +/** Which fee is charged instead of the one the membership works out to. */ +export function reChargedDescription( + row: BulkRow, + fees: Record, +): string { + const charged = feeTypeLabels[fees[row.userId]!].toLowerCase() + const applies = row.recommendedFeeType ? feeTypeLabels[row.recommendedFeeType].toLowerCase() : "none" + return `${row.name} is charged the ${charged} instead of the ${applies} that applies` +} + +/** When this member last got the email they are getting now. */ +export function lastSentOn( + row: BulkRow, + chosen: Record, +): string | undefined { + return kindFor(row, chosen) === ContributionEmailKind.INCASSO_NOTIFICATION + ? row.lastNotifiedOn + : row.lastRemindedOn +} + +export function lastSentLabel(iso: string | undefined): string { + const formatted = formatBulkDate(iso) + // The absence is the answer here, so it reads as a word rather than an em dash. + return formatted === "—" ? "Never" : formatted +} + +/** A member this send can reach at all, so the wizard offers them a Send-to box. */ +export function isSelectable(row: BulkRow): boolean { + return row.disposition !== "EXCLUDED" +} + +/** + * Which rows the wizard starts ticked. A warned row starts unticked, so the safe choice is + * the one the treasurer gets by doing nothing; a member it cannot reach has no box at all. + */ +export function seedSendTo(rows: BulkRow[]): Record { + const sendTo: Record = {} + for (const row of rows) { + if (isSelectable(row)) sendTo[row.userId] = row.disposition === "INCLUDED" + } + return sendTo +} + +/** The three per-member choices the wizard holds, so a re-read can put them back. */ +export interface PaymentEmailChoices { + sendTo: Record + fees: Record + kinds: Record +} + +/** What a freshly read plan proposes before the treasurer has touched anything. */ +export function seedChoices(rows: BulkRow[]): PaymentEmailChoices { + const fees: Record = {} + const kinds: Record = {} + for (const row of rows) { + if (row.recommendedFeeType) fees[row.userId] = row.recommendedFeeType + if (row.defaultKind) kinds[row.userId] = row.defaultKind + } + return {sendTo: seedSendTo(rows), fees, kinds} +} + +/** + * The choices already made, carried onto a plan that has just been read again. A member the + * refusal named keeps the new plan's answer instead: the api has contradicted what was chosen + * for them. So does a member the plan no longer offers a box to. + */ +export function reapplyChoices( + rows: BulkRow[], + seeded: PaymentEmailChoices, + made: PaymentEmailChoices, + contradicted: number[], +): PaymentEmailChoices { + const lost = new Set(contradicted) + const next: PaymentEmailChoices = { + sendTo: {...seeded.sendTo}, + fees: {...seeded.fees}, + kinds: {...seeded.kinds}, + } + for (const row of rows) { + const userId = row.userId + if (lost.has(userId) || !isSelectable(row)) continue + if (made.sendTo[userId] !== undefined) next.sendTo[userId] = made.sendTo[userId] + if (made.fees[userId] !== undefined) next.fees[userId] = made.fees[userId] + if (made.kinds[userId] !== undefined) next.kinds[userId] = made.kinds[userId] + } + return next +} + +/** The Send-to box is the selection: a row sends when it is ticked, and only then. */ +export function willSend(row: BulkRow, sendTo: Record): boolean { + return isSelectable(row) && !!sendTo[row.userId] +} + +/** Ticked rows the api warned about. It re-decides on send, so it is told to overrule. */ +export function forcedUserIds(rows: BulkRow[], sendTo: Record): number[] { + return rows + .filter((row) => row.disposition === "WARNING" && willSend(row, sendTo)) + .map((row) => row.userId) +} + +/** + * A payment date may run this far past the period's end, so August chasing stays possible. + * The api's copy is `MONTHS_PAST_PERIOD_END` in `BulkContributionEmailUseCases`. + */ +export const PERIOD_OVERHANG_MONTHS = 3 + +type PeriodDates = Pick + +/** The window a payment due date or debit date may fall in. */ +export function periodDateWindow( + period: PeriodDates | null | undefined, +): {from: string; until: string} | null { + if (!period) return null + return { + from: period.startDate, + until: DateTime.fromISO(period.endDate) + .plus({months: PERIOD_OVERHANG_MONTHS}) + .toFormat("yyyy-MM-dd"), + } +} + +/** + * Why this date cannot be sent, or null when it can. + * + * The api enforces the same rules on send — `@Future` on the field, then `dateViolations` + * in `BulkContributionEmailUseCases` for the bounds — mirrored here the way `effectiveAmount` + * and `resolveFeeAmount` name each other. Both sides judge every date that is sent; a date no + * recipient needs is left out of the request. Changing one means changing the other. + */ +export function paymentDateProblem( + iso: string, + period: PeriodDates | null | undefined, + today: string, +): string | null { + if (!iso) return null + if (iso <= today) return "The date must be after today." + const window = periodDateWindow(period) + if (!window) return null + if (iso < window.from || iso > window.until) { + return `The date must fall between ${formatBulkDate(window.from)} and ${formatBulkDate(window.until)}.` + } + return null +} + +/** How many of each email this send would put out. */ +export function countByKind( + rows: BulkRow[], + chosen: Record, + sendTo: Record, +): Record { + const counts = { + [ContributionEmailKind.REMINDER]: 0, + [ContributionEmailKind.INCASSO_NOTIFICATION]: 0, + } + for (const row of rows) { + if (willSend(row, sendTo)) counts[kindFor(row, chosen)]++ + } + return counts +} + +/** What the send is about to do, for the confirmation that stands in front of it. */ +/** One member behind a line in the summary, so the confirmation can name them. */ +export interface FlaggedMember { + userId: number + name: string + /** What is true of this member, stated in full so the line needs no decoding. */ + note: string +} + +export interface PaymentEmailSummary { + reminders: number + incassoNotifications: number + total: number + /** Selected members the batch leaves alone, whether unticked or never reachable. */ + notEmailed: number + /** Warned members the treasurer included anyway, each with the warning they overruled. */ + forced: FlaggedMember[] + /** Members moved off the email their direct-debit flag chose. */ + switched: FlaggedMember[] + /** Members charged a fee type other than the one that applies. */ + reCharged: FlaggedMember[] + /** Recipients who have had this same email for this period before. */ + alreadySent: FlaggedMember[] +} + +export function summarise( + rows: BulkRow[], + kinds: Record, + fees: Record, + sendTo: Record, +): PaymentEmailSummary { + const recipients = rows.filter((row) => willSend(row, sendTo)) + const counts = countByKind(rows, kinds, sendTo) + return { + reminders: counts[ContributionEmailKind.REMINDER], + incassoNotifications: counts[ContributionEmailKind.INCASSO_NOTIFICATION], + total: recipients.length, + notEmailed: rows.length - recipients.length, + forced: recipients + .filter((row) => row.disposition === "WARNING") + .map((row) => ({userId: row.userId, name: row.name, note: reasonLabel(row.reason)})), + switched: recipients + .filter((row) => isSwitched(row, kinds)) + .map((row) => ({userId: row.userId, name: row.name, note: switchedDescription(row, kinds)})), + reCharged: recipients + .filter((row) => isReCharged(row, fees)) + .map((row) => ({userId: row.userId, name: row.name, note: reChargedDescription(row, fees)})), + alreadySent: recipients + .filter((row) => !!lastSentOn(row, kinds)) + .map((row) => ({ + userId: row.userId, + name: row.name, + note: `last sent ${lastSentLabel(lastSentOn(row, kinds))}`, + })), + } +} + +/** Only the rows the treasurer moved off the email their flag chose. */ +export function changedKinds( + rows: BulkRow[], + chosen: Record, +): Record { + const changed: Record = {} + for (const row of rows) { + if (isSwitched(row, chosen)) changed[String(row.userId)] = kindFor(row, chosen) + } + return changed +} + +/** + * Only the fee types the treasurer changed. Stating every row would claim a choice where + * none was made, and the api refuses a type naming somebody it does not write to. + */ +export function changedFeeTypes( + rows: BulkRow[], + selections: Record, +): Record { + const changed: Record = {} + for (const row of rows) { + const chosen = selections[row.userId] + if (chosen && isReCharged(row, selections)) changed[String(row.userId)] = chosen + } + return changed +} diff --git a/services/frontend/src/utils/feeCycle.ts b/services/frontend/src/utils/feeCycle.ts deleted file mode 100644 index 6989bad19..000000000 --- a/services/frontend/src/utils/feeCycle.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type {FeeCycleRowResponse} from "@/services/api" -import {formatBulkDate} from "@/utils/bulkDisposition" -import {BulkFeeType, FeeCycleGroup, type BulkRow} from "@/utils/bulkRow" - -/** - * The fee cycle's rows, as the dialog reads them. - * - * Unlike the paid/unpaid dialogs, these are not computed in the browser: the cycle is over - * every unpaid member of a period rather than over a selection the page already holds, and - * the api decides it once so the preview and the send cannot disagree. This is the mapping - * from that answer onto the row shape the shared scaffold renders. - * - * A straight mapping rather than the join `bulkRowsFromPreview` does for the membership - * actions, because the row already carries the member details. `memberSince` in particular - * is the start of the membership the api *judged* — the active one where the member has one - * — and a browser deriving it would be a second implementation of that rule, free to - * disagree with the one that decided the partition. - */ - -/** One row, plus the side of the partition, which the scaffold renders as its own column. */ -export function toBulkRow(row: FeeCycleRowResponse): BulkRow { - return { - userId: row.userId, - name: row.name, - disposition: row.disposition, - reason: row.reason ?? undefined, - memberType: row.memberType, - memberSince: row.memberSince ?? undefined, - amount: row.amount ?? null, - recommendedFeeType: row.feeType ?? undefined, - lastSentOn: row.lastAskedOn ?? undefined, - group: row.group, - } -} - -export function toBulkRows(rows: FeeCycleRowResponse[]): BulkRow[] { - return rows.map(toBulkRow) -} - -export const feeCycleGroupLabels: Record = { - [FeeCycleGroup.DIRECT_DEBIT]: "Direct debit", - [FeeCycleGroup.TRANSFER]: "Transfer", -} - -export function feeCycleGroupLabel(group: FeeCycleGroup | undefined): string { - return group ? feeCycleGroupLabels[group] : "—" -} - -/** Two chips rather than one, because the count per group is the thing being checked. */ -export function countByGroup(rows: BulkRow[]): Record { - return { - DIRECT_DEBIT: rows.filter((row) => row.group === FeeCycleGroup.DIRECT_DEBIT).length, - TRANSFER: rows.filter((row) => row.group === FeeCycleGroup.TRANSFER).length, - } -} - -/** - * A member never asked reads as never, not as an empty cell — the treasurer is looking for - * the ones already asked, so the absence has to be as legible as the date. - */ -export function lastAskedLabel(iso: string | undefined): string { - const formatted = formatBulkDate(iso) - // formatBulkDate is the one date format every bulk dialog uses; only the word for an - // absent one differs, because here the absence is the answer rather than missing data. - return formatted === "—" ? "Never" : formatted -} - -/** - * Whether this member has already been asked for this period on this side of the partition. - * - * Sending again is allowed, as often as the treasurer needs, so an already-asked member - * stays included rather than becoming a row to tick back in — half way through the year that - * would be a hundred ticks. It is warned about instead, and each ask is its own record, so - * the warning is a note about history rather than a constraint. - */ -export function askedAlready(row: BulkRow): boolean { - return !!row.lastSentOn -} - -/** How many of the members about to be written to have already been asked. */ -export function countAskedAlready(rows: BulkRow[]): number { - return rows.filter((row) => row.disposition === "INCLUDED" && askedAlready(row)).length -} - -/** - * Only the fee types the treasurer actually changed. - * - * Sending every row's type would make the request state a choice where none was made, and - * the api refuses a type naming somebody it does not write to — so a row that dropped out - * of the cycle since the preview would refuse a send the treasurer did not ask to change. - */ -export function changedFeeTypes( - rows: BulkRow[], - selections: Record, -): Record { - const changed: Record = {} - for (const row of rows) { - const chosen = selections[row.userId] - if (chosen && chosen !== row.recommendedFeeType) changed[String(row.userId)] = chosen - } - return changed -} diff --git a/services/frontend/tests/e2e/mocks.ts b/services/frontend/tests/e2e/mocks.ts index f3a86ee87..49214b1eb 100644 --- a/services/frontend/tests/e2e/mocks.ts +++ b/services/frontend/tests/e2e/mocks.ts @@ -31,6 +31,8 @@ type Fixtures = { esportsGames?: Array> boards?: Array> cohortSubjectDetail?: Record + /** A refusal the payment-email send answers with instead of accepting the batch. */ + paymentEmailRefusal?: {status: number; errors: Array>} } /** What Brevo reports it holds, for the target catalogue page. */ @@ -691,40 +693,46 @@ export async function installApiMocks(page: Page, fixtures: Fixtures = {}) { queued: 0, }) } - // The fee cycle is over a period rather than a selection, so the api answers with the - // whole partition and the browser only renders it. The mock decides the rows the way the - // server would: unpaid members of the period, split by their direct-debit flag. - if (method === "GET" && path === "/contributions/fee-cycle") { - const periodId = Number(new URL(route.request().url()).searchParams.get("contributionPeriodId")) + // The api decides these rows, so the mock decides them the same way: hard exclusions + // first, then the warnings the operator can tick back in. + if (method === "POST" && path === "/contributions/bulk/email/preview") { + const body = route.request().postDataJSON() as {contributionPeriodId: number; userIds: number[]} const paid = new Set( - baseContributions.filter((c) => c.contributionPeriodId === periodId).map((c) => c.userId), + baseContributions + .filter((c) => c.contributionPeriodId === body.contributionPeriodId) + .map((c) => c.userId), ) - const rows = baseMemberships - .filter((m) => !paid.has(m.userId)) - .map((m) => { - const honorary = m.memberType === "HONORARY" - return { - userId: m.userId, - name: baseUsers.find((u) => Number(u.id) === m.userId)?.fullName ?? `#${m.userId}`, - memberType: m.memberType, - memberSince: m.startDate, - group: m.incasso ? "DIRECT_DEBIT" : "TRANSFER", - disposition: honorary ? "EXCLUDED" : "INCLUDED", - reason: honorary ? "HONORARY" : null, - feeType: honorary ? null : "FULL_YEAR_FEE", - amount: honorary ? null : 20, - lastAskedOn: m.userId === 2 ? "2025-09-01" : null, - } - }) - return fulfillJson(route, {contributionPeriodId: periodId, rows}) + const rows = body.userIds.map((userId) => { + const membership = baseMemberships.find((m) => m.userId === userId) + const honorary = membership?.memberType === "HONORARY" + const [disposition, reason] = honorary + ? ["EXCLUDED", "HONORARY"] + : paid.has(userId) + ? ["WARNING", "ALREADY_PAID"] + : ["INCLUDED", null] + return { + userId, + name: baseUsers.find((u) => Number(u.id) === userId)?.fullName ?? `#${userId}`, + memberType: membership?.memberType ?? "NONE", + memberSince: membership?.startDate ?? null, + disposition, + reason, + defaultKind: membership?.incasso ? "INCASSO_NOTIFICATION" : "REMINDER", + feeType: honorary ? null : "FULL_YEAR_FEE", + amount: honorary ? null : 20, + lastRemindedOn: userId === 2 ? "2025-09-01" : null, + lastNotifiedOn: null, + } + }) + return fulfillJson(route, {contributionPeriodId: body.contributionPeriodId, rows, unknownUserIds: []}) } - if (method === "GET" && path === "/contributions/fee-cycle/email-preview") { + if (method === "GET" && path === "/contributions/bulk/email/message") { const params = new URL(route.request().url()).searchParams - const direct = params.get("userId") === "3" + const incasso = params.get("kind") === "INCASSO_NOTIFICATION" return fulfillJson(route, { - group: direct ? "DIRECT_DEBIT" : "TRANSFER", + kind: params.get("kind") ?? "REMINDER", feeType: params.get("feeType") ?? "FULL_YEAR_FEE", - subject: direct + subject: incasso ? "Your Blueshell contribution will be collected automatically (2025)" : "Please pay your Blueshell contribution (2025)", html: "

Amount due: €20,00

", @@ -732,8 +740,21 @@ export async function installApiMocks(page: Page, fixtures: Fixtures = {}) { recipientName: "A Member", }) } - if (method === "POST" && path === "/contributions/fee-cycle/send") { - return fulfillJson(route, {paymentRequestsQueued: 1, preNotificationsQueued: 1, excluded: 1}) + if (method === "POST" && path === "/contributions/bulk/email/send") { + const refusal = fixtures.paymentEmailRefusal + if (refusal) { + return fulfillJson( + route, + {status: refusal.status, detail: "The send was refused.", errors: refusal.errors}, + refusal.status, + ) + } + const body = route.request().postDataJSON() as {userIds: number[]} + return fulfillJson(route, { + remindersSent: body.userIds.length, + incassoNotificationsSent: 0, + notWrittenTo: 0, + }) } if (method === "GET" && path === "/contributionPeriods") { return fulfillJson(route, basePeriods) diff --git a/services/frontend/tests/e2e/user-manager-fee-cycle.spec.ts b/services/frontend/tests/e2e/user-manager-fee-cycle.spec.ts deleted file mode 100644 index 605366248..000000000 --- a/services/frontend/tests/e2e/user-manager-fee-cycle.spec.ts +++ /dev/null @@ -1,120 +0,0 @@ -import {expect, test, type Page} from "./test" -import {installApiMocks, loginAsBoard} from "./mocks" - -/** - * Asking a period's unpaid members for what they owe, from the menu to the result. - * - * Driven end to end rather than asserted in a unit test because the dialog decides nothing: - * it asks the api for the partition and renders the answer, so a spec that never lets the - * request happen would be watching something other than what the treasurer sees. - */ - -/** One member on each side of the partition, plus an honorary one the cycle cannot write to. */ -const MEMBERSHIPS = [ - {id: 100, userId: 1, memberType: "REGULAR", startDate: "2025-01-01", incasso: false}, - {id: 101, userId: 2, memberType: "REGULAR", startDate: "2025-02-01", incasso: true}, - {id: 102, userId: 3, memberType: "HONORARY", startDate: "2025-01-01", incasso: false}, -] - -const USERS = [ - {id: 1, fullName: "Emma Dokter", username: "lyndisluna", enabled: true, roles: ["MEMBER"]}, - {id: 2, fullName: "Viktor Petrov", username: "ariosfury", enabled: true, roles: ["MEMBER"]}, - {id: 3, fullName: "Hanne Erelid", username: "hanne", enabled: true, roles: ["MEMBER"]}, -] - -async function openFeeCycle(page: Page): Promise { - await page.setViewportSize({width: 1400, height: 900}) - // No contributions, so nobody has paid and the whole membership list is in the cycle. - await installApiMocks(page, {users: USERS, memberships: MEMBERSHIPS, contributions: []}) - await loginAsBoard(page.context()) - await page.goto("/user-manager") - await page.getByTestId("member-manager-table").waitFor() - await page.getByTestId("bulk-actions-menu-btn").click() - await page.getByTestId("bulk-action-fee-cycle").click() - await page.getByTestId("bulk-action-dialog").waitFor() -} - -test.describe("the fee cycle", () => { - test("shows both sides of the partition, and who it will not write to", async ({page}) => { - await openFeeCycle(page) - - await expect(page.getByTestId("fee-cycle-group-1")).toContainText("Transfer") - await expect(page.getByTestId("fee-cycle-group-2")).toContainText("Direct debit") - await expect(page.getByTestId("fee-cycle-count-transfer")).toContainText("2 by transfer") - await expect(page.getByTestId("fee-cycle-count-direct-debit")).toContainText("1 by direct debit") - - // Excluded rather than omitted, so the honorary member's absence is visible. - await expect(page.getByTestId("bulk-preview-disposition-3")).toContainText("Excluded") - await expect(page.getByTestId("bulk-preview-note-3")).toContainText("Honorary") - }) - - test("prices each row, and re-prices one when its fee type changes", async ({page}) => { - await openFeeCycle(page) - - await expect(page.getByTestId("fee-cycle-amount-1")).toContainText("20.00") - - await page.getByTestId("fee-cycle-feetype-1").click() - await page.getByRole("option", {name: "Alumni fee"}).click() - - // The period's alumni fee, worked out in the browser: no round trip to re-price a row. - await expect(page.getByTestId("fee-cycle-amount-1")).toContainText("5.00") - }) - - test("says which members have already been asked, without shutting them out", async ({page}) => { - await openFeeCycle(page) - - await expect(page.getByTestId("fee-cycle-asked-already-warning")) - .toContainText("already been asked for this period") - await expect(page.getByTestId("fee-cycle-last-asked-2")).toContainText("01/09/2025") - await expect(page.getByTestId("fee-cycle-last-asked-1")).toContainText("Never") - // Asked already, still included: chasing is the job. - await expect(page.getByTestId("bulk-preview-disposition-2")).toContainText("Included") - }) - - test("reads one member's email before sending to any of them", async ({page}) => { - await openFeeCycle(page) - - await page.getByTestId("fee-cycle-payment-due-date").locator("input").fill("2026-12-01") - await page.getByTestId("fee-cycle-debit-date").locator("input").fill("2026-12-15") - await page.getByTestId("fee-cycle-preview-email-btn").click() - - await expect(page.getByTestId("email-preview-subject")).toContainText("Please pay your Blueshell contribution") - await expect(page.getByTestId("email-preview-frame")).toBeVisible() - }) - - test("sends both statements from one confirmation and reports each side", async ({page}) => { - await openFeeCycle(page) - - await page.getByTestId("fee-cycle-payment-due-date").locator("input").fill("2026-12-01") - await page.getByTestId("fee-cycle-debit-date").locator("input").fill("2026-12-15") - await page.getByTestId("bulk-action-confirm-btn").click() - - await expect(page.getByTestId("bulk-action-dialog")).toBeHidden({timeout: 5000}) - }) - - test("will not send without both dates", async ({page}) => { - await openFeeCycle(page) - - await page.getByTestId("bulk-action-confirm-btn").click() - - // The dialog stays open with the fields complaining, rather than sending a cycle that - // promises no date. - await expect(page.getByTestId("bulk-action-dialog")).toBeVisible() - await expect(page.getByTestId("fee-cycle-payment-due-date")).toContainText("required") - await expect(page.getByTestId("fee-cycle-debit-date")).toContainText("required") - }) -}) - -test.describe("the fee cycle without a period", () => { - test("is offered but inert, because there is no cycle without one", async ({page}) => { - await page.setViewportSize({width: 1400, height: 900}) - await installApiMocks(page, {users: USERS, memberships: MEMBERSHIPS, contributionPeriods: []}) - await loginAsBoard(page.context()) - await page.goto("/user-manager") - await page.getByTestId("member-manager-table").waitFor() - - await page.getByTestId("bulk-actions-menu-btn").click() - - await expect(page.getByTestId("bulk-action-fee-cycle")).toHaveClass(/v-list-item--disabled/) - }) -}) diff --git a/services/frontend/tests/e2e/user-manager-payment-emails.spec.ts b/services/frontend/tests/e2e/user-manager-payment-emails.spec.ts new file mode 100644 index 000000000..91a92da28 --- /dev/null +++ b/services/frontend/tests/e2e/user-manager-payment-emails.spec.ts @@ -0,0 +1,304 @@ +import {expect, test, type Page} from "./test" +import {installApiMocks, loginAsBoard} from "./mocks" + +/** + * Sending a period's payment emails, from the menu to the result. Driven end to end because + * the wizard decides nothing: it hands the api the selection and renders the answer, and a + * refusal puts the treasurer back on the step that owns it. + */ + +/** Dates move with the clock, because every date the wizard accepts sits against the period. */ +const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10) + +const PERIOD = { + id: 201, + startDate: day(-60), + endDate: day(240), + halfYearCutoffDate: day(90), + halfYearFee: 10, + fullYearFee: 20, + alumniFee: 5, +} + +const DUE_DATE = day(30) +const DEBIT_DATE = day(45) + +/** One who transfers, one on direct debit, an honorary one, and one who has already paid. */ +const MEMBERSHIPS = [ + {id: 100, userId: 1, memberType: "REGULAR", startDate: "2025-01-01", incasso: false}, + {id: 101, userId: 2, memberType: "REGULAR", startDate: "2025-02-01", incasso: true}, + {id: 102, userId: 3, memberType: "HONORARY", startDate: "2025-01-01", incasso: false}, + {id: 103, userId: 4, memberType: "REGULAR", startDate: "2025-03-01", incasso: false}, +] + +const USERS = [ + {id: 1, fullName: "Emma Dokter", username: "lyndisluna", enabled: true, roles: ["MEMBER"]}, + {id: 2, fullName: "Viktor Petrov", username: "ariosfury", enabled: true, roles: ["MEMBER"]}, + {id: 3, fullName: "Hanne Erelid", username: "hanne", enabled: true, roles: ["MEMBER"]}, + {id: 4, fullName: "Sanne Bakker", username: "sanne", enabled: true, roles: ["MEMBER"]}, +] + +const CONTRIBUTIONS = [{id: 300, userId: 4, contributionPeriodId: PERIOD.id}] + +type Refusal = {status: number; errors: Array>} + +async function openPaymentEmails( + page: Page, + options: {select?: number[]; refusal?: Refusal} = {}, +): Promise { + const select = options.select ?? [1, 2, 3, 4] + await page.setViewportSize({width: 1400, height: 900}) + await installApiMocks(page, { + users: USERS, + memberships: MEMBERSHIPS, + contributionPeriods: [PERIOD], + contributions: CONTRIBUTIONS, + paymentEmailRefusal: options.refusal, + }) + await loginAsBoard(page.context()) + await page.goto("/user-manager") + await page.getByTestId("member-manager-table").waitFor() + for (const id of select) { + await page.getByTestId(`member-manager-checkbox-${id}`).locator("input").click() + } + await page.getByTestId("bulk-actions-menu-btn").click() + await page.getByTestId("bulk-action-send-payment-emails").click() + await page.getByTestId("payment-emails-members-table").waitFor() +} + +const next = (page: Page) => page.getByTestId("payment-emails-next-btn").click() + +/** Steps 1 and 2 ask nothing that has to be answered, so the dates are the only stop. */ +async function goToTheLastStep(page: Page): Promise { + await next(page) + await page.getByTestId("payment-emails-fees-table").waitFor() + await next(page) + await page.getByTestId("payment-emails-payment-due-date").waitFor() +} + +async function fillDates(page: Page): Promise { + await page.getByTestId("payment-emails-payment-due-date").locator("input").fill(DUE_DATE) + await page.getByTestId("payment-emails-debit-date").locator("input").fill(DEBIT_DATE) +} + +test.describe("step 1, who the batch writes to", () => { + test("ticks who the api would write to, and says why the others are not", async ({page}) => { + await openPaymentEmails(page) + + await expect(page.getByTestId("payment-emails-send-to-1").locator("input")).toBeChecked() + await expect(page.getByTestId("payment-emails-send-to-2").locator("input")).toBeChecked() + await expect(page.getByTestId("payment-emails-send-to-4").locator("input")).not.toBeChecked() + await expect(page.getByTestId("payment-emails-send-to-3")).toBeHidden() + await expect(page.getByTestId("payment-emails-reason-3")).toContainText("Owes no contribution") + await expect(page.getByTestId("payment-emails-reason-4")) + .toContainText("Already paid this contribution") + }) + + test("counts what is ticked, and moves when a warned member is ticked back in", async ({page}) => { + await openPaymentEmails(page) + + await expect(page.getByTestId("payment-emails-count-recipients")).toContainText("2 of 4") + await expect(page.getByTestId("payment-emails-count-reminders")) + .toContainText("1 contribution reminder") + await expect(page.getByTestId("payment-emails-count-notifications")) + .toContainText("1 incasso notification") + await expect(page.getByTestId("payment-emails-count-excluded")).toContainText("1 cannot be emailed") + + await page.getByTestId("payment-emails-send-to-4").locator("input").click() + + await expect(page.getByTestId("payment-emails-count-recipients")).toContainText("3 of 4") + await expect(page.getByTestId("payment-emails-count-reminders")) + .toContainText("2 contribution reminders") + }) + + test("unticking a member drops them from the rest of the wizard", async ({page}) => { + await openPaymentEmails(page) + + await page.getByTestId("payment-emails-send-to-2").locator("input").click() + await next(page) + + await expect(page.getByTestId("payment-emails-fee-row-1")).toBeVisible() + await expect(page.getByTestId("payment-emails-fee-row-2")).toBeHidden() + }) +}) + +test.describe("step 2, the fees and the emails", () => { + test("moving a member onto the other email warns by name and flags the row", async ({page}) => { + await openPaymentEmails(page) + await next(page) + + await page.getByTestId("payment-emails-kind-2").click() + await page.getByRole("option", {name: "Contribution reminder"}).click() + + await expect(page.getByTestId("payment-emails-kind-warning")).toContainText("Viktor Petrov") + await expect(page.getByTestId("payment-emails-switched-2")).toContainText("Pays by direct debit") + await expect(page.getByTestId("payment-emails-count-reminders")) + .toContainText("2 contribution reminders") + }) + + test("prices each row, and re-prices one when its fee type changes", async ({page}) => { + await openPaymentEmails(page) + await next(page) + + await expect(page.getByTestId("payment-emails-amount-1")).toContainText("20.00") + + await page.getByTestId("payment-emails-feetype-1").click() + await page.getByRole("option", {name: "Alumni fee"}).click() + + // The period's alumni fee, worked out in the browser: no round trip to re-price a row. + await expect(page.getByTestId("payment-emails-amount-1")).toContainText("5.00") + await expect(page.getByTestId("payment-emails-fee-warning")).toContainText("Emma Dokter") + }) + + test("last sent follows the email the row is set to", async ({page}) => { + await openPaymentEmails(page) + await next(page) + + // Viktor was reminded on 01/09 but is getting a notification, which he has never had. + await expect(page.getByTestId("payment-emails-last-sent-2")).toContainText("Never") + + await page.getByTestId("payment-emails-kind-2").click() + await page.getByRole("option", {name: "Contribution reminder"}).click() + + await expect(page.getByTestId("payment-emails-last-sent-2")).toContainText("01/09/2025") + }) +}) + +test.describe("step 3, what will be sent", () => { + test("lists each recipient, and previews the email one of them gets", async ({page}) => { + await openPaymentEmails(page) + await goToTheLastStep(page) + + await expect(page.getByTestId("payment-emails-recipient-1")).toContainText("Emma Dokter") + await expect(page.getByTestId("payment-emails-recipient-3")).toBeHidden() + + await fillDates(page) + await page.getByTestId("payment-emails-preview-1").click() + + await expect(page.getByTestId("email-preview-subject")) + .toContainText("Please pay your Blueshell contribution") + await expect(page.getByTestId("email-preview-frame")).toBeVisible() + }) + + test("will not send without the dates the batch needs", async ({page}) => { + await openPaymentEmails(page) + await goToTheLastStep(page) + + await expect(page.getByTestId("payment-emails-payment-due-date")).toContainText("required") + await expect(page.getByTestId("payment-emails-debit-date")).toContainText("required") + await expect(page.getByTestId("payment-emails-next-btn")).toBeDisabled() + }) + + test("a date nobody in the batch needs is optional and says so", async ({page}) => { + await openPaymentEmails(page, {select: [1]}) + await goToTheLastStep(page) + + await expect(page.getByTestId("payment-emails-debit-date")) + .toContainText("Nobody here is on direct debit") + + await page.getByTestId("payment-emails-payment-due-date").locator("input").fill(DUE_DATE) + await next(page) + await page.getByTestId("payment-emails-confirm-send-btn").click() + + await expect(page.getByTestId("payment-emails-wizard")).toBeHidden({timeout: 5000}) + }) +}) + +test.describe("the confirmation", () => { + test("Send opens a summary, and nothing goes out until it is confirmed", async ({page}) => { + await openPaymentEmails(page) + await goToTheLastStep(page) + await fillDates(page) + + await next(page) + + await expect(page.getByTestId("payment-emails-confirm-summary")).toBeVisible() + await expect(page.getByTestId("payment-emails-confirm-reminders")).toContainText("1") + await expect(page.getByTestId("payment-emails-confirm-notifications")).toContainText("1") + await expect(page.getByTestId("payment-emails-confirm-not-emailed")) + .toContainText("2 selected members get no email") + + await page.getByTestId("payment-emails-confirm-send-btn").click() + + await expect(page.getByTestId("payment-emails-wizard")).toBeHidden({timeout: 5000}) + }) + + test("backing out returns to the last step with everything intact", async ({page}) => { + await openPaymentEmails(page) + await goToTheLastStep(page) + await fillDates(page) + await next(page) + + await page.getByTestId("payment-emails-confirm-back-btn").click() + + await expect(page.getByTestId("payment-emails-confirm-summary")).toBeHidden() + await expect(page.getByTestId("payment-emails-payment-due-date").locator("input")) + .toHaveValue(DUE_DATE) + }) +}) + +test.describe("a refusal from the api", () => { + test("turns the date input red, on the step that owns it", async ({page}) => { + await openPaymentEmails(page, { + refusal: { + status: 400, + errors: [{ + objectName: "SendPaymentEmailsRequest", + field: "paymentDueDate", + code: "DateOutsideContributionPeriod", + message: "A date must fall within the contribution period, or shortly after it ends.", + }], + }, + }) + await goToTheLastStep(page) + await fillDates(page) + await next(page) + + await page.getByTestId("payment-emails-confirm-send-btn").click() + + await expect(page.getByTestId("payment-emails-confirm-summary")).toBeHidden() + await expect(page.getByTestId("payment-emails-rejection")).toContainText("Nothing was sent") + const dueDate = page.getByTestId("payment-emails-payment-due-date") + await expect(dueDate).toContainText("must fall within the contribution period") + await expect(dueDate).toHaveClass(/v-input--error/) + }) + + test("puts the treasurer back on the member step with the rows it named", async ({page}) => { + await openPaymentEmails(page, { + refusal: { + status: 409, + errors: [{ + objectName: "SendPaymentEmailsRequest", + field: "userIds", + code: "UnknownUserIds", + message: "1 of the selected users no longer exist.", + values: [2], + }], + }, + }) + await goToTheLastStep(page) + await fillDates(page) + await next(page) + + await page.getByTestId("payment-emails-confirm-send-btn").click() + + await expect(page.getByTestId("payment-emails-members-table")).toBeVisible() + await expect(page.getByTestId("payment-emails-refusal-2")).toContainText("no longer exist") + await expect(page.getByTestId("payment-emails-refusal-1")).toBeHidden() + }) +}) + +test.describe("the payment emails action without a selection", () => { + test("is offered but inert, because there is nobody to write to", async ({page}) => { + await page.setViewportSize({width: 1400, height: 900}) + await installApiMocks(page, {users: USERS, memberships: MEMBERSHIPS}) + await loginAsBoard(page.context()) + await page.goto("/user-manager") + await page.getByTestId("member-manager-table").waitFor() + + await page.getByTestId("bulk-actions-menu-btn").click() + + await expect(page.getByTestId("bulk-action-send-payment-emails")) + .toHaveClass(/v-list-item--disabled/) + }) +}) diff --git a/services/frontend/tests/unit/components/common/modals/bulk/FeeCycle.test.ts b/services/frontend/tests/unit/components/common/modals/bulk/FeeCycle.test.ts deleted file mode 100644 index ec048777d..000000000 --- a/services/frontend/tests/unit/components/common/modals/bulk/FeeCycle.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import {describe, expect, it, vi} from "vitest" -import {mount} from "@vue/test-utils" -import FeeCycleDialog from "@/components/common/modals/bulk/FeeCycleDialog.vue" -import { - BulkFeeType, - BulkRowDisposition, - BulkRowReason, - FeeCycleGroup, - MemberType, - type ContributionPeriodResponse, - type FeeCycleRowResponse, -} from "@/services/api" -import {settle} from "../../../../helpers/testUtils" - -const {mockPreviewFeeCycle, mockSendFeeCycle, mockPreviewFeeCycleEmail} = vi.hoisted(() => ({ - mockPreviewFeeCycle: vi.fn(), - mockSendFeeCycle: vi.fn(), - mockPreviewFeeCycleEmail: vi.fn(), -})) -vi.mock("@/services/api/blueshell/sdk.gen", () => ({ - previewFeeCycle: mockPreviewFeeCycle, - sendFeeCycle: mockSendFeeCycle, - previewFeeCycleEmail: mockPreviewFeeCycleEmail, -})) - -const period: ContributionPeriodResponse = { - id: 3, - startDate: "2025-09-01", - endDate: "2026-08-31", - halfYearCutoffDate: "2026-02-01", - halfYearFee: 25, - fullYearFee: 45, - alumniFee: 10, - version: 0, - createdAt: "2025-09-01T00:00:00Z", - updatedAt: "2025-09-01T00:00:00Z", -} - -function apiRow(overrides: Partial = {}): FeeCycleRowResponse { - return { - userId: 1, - name: "Ann Regular", - memberType: MemberType.REGULAR, - memberSince: "2025-09-01", - group: FeeCycleGroup.TRANSFER, - disposition: BulkRowDisposition.INCLUDED, - reason: null, - feeType: BulkFeeType.FULL_YEAR_FEE, - amount: 45, - lastAskedOn: null, - ...overrides, - } -} - -function givenCycle(rows: FeeCycleRowResponse[]) { - mockPreviewFeeCycle.mockResolvedValue({data: {contributionPeriodId: period.id, rows}}) -} - -async function openDialog(rows: FeeCycleRowResponse[]) { - givenCycle(rows) - const wrapper = mount(FeeCycleDialog, {props: {modelValue: true, period}}) - await settle() - return wrapper -} - -describe("FeeCycleDialog", () => { - it("reads the cycle from the api for the selected period", async () => { - await openDialog([apiRow()]) - - expect(mockPreviewFeeCycle).toHaveBeenCalledWith({query: {contributionPeriodId: 3}}) - }) - - it("shows both sides of the partition and counts each", async () => { - const wrapper = await openDialog([ - apiRow({userId: 1, group: FeeCycleGroup.TRANSFER}), - apiRow({userId: 2, name: "Ben Debit", group: FeeCycleGroup.DIRECT_DEBIT}), - ]) - - expect(wrapper.find('[data-testid="fee-cycle-group-1"]').text()).toBe("Transfer") - expect(wrapper.find('[data-testid="fee-cycle-group-2"]').text()).toBe("Direct debit") - expect(wrapper.find('[data-testid="fee-cycle-count-transfer"]').text()).toContain("1 by transfer") - expect(wrapper.find('[data-testid="fee-cycle-count-direct-debit"]').text()).toContain("1 by direct debit") - }) - - it("shows the amount the api resolved for each row", async () => { - const wrapper = await openDialog([apiRow({userId: 1, feeType: BulkFeeType.HALF_YEAR_FEE, amount: 25})]) - - expect(wrapper.find('[data-testid="fee-cycle-amount-1"]').text()).toContain("25.00") - }) - - // Their absence from the send is visible rather than silent. - it("shows an excluded member with the reason, and offers no fee type for them", async () => { - const wrapper = await openDialog([ - apiRow({ - userId: 4, - name: "Cara Honorary", - memberType: MemberType.HONORARY, - disposition: BulkRowDisposition.EXCLUDED, - reason: BulkRowReason.HONORARY, - feeType: null, - amount: null, - }), - ]) - - expect(wrapper.find('[data-testid="bulk-preview-disposition-4"]').text()).toContain("Excluded") - expect(wrapper.find('[data-testid="bulk-preview-note-4"]').text()).toContain("Honorary") - expect(wrapper.find('[data-testid="fee-cycle-feetype-4"]').exists()).toBe(false) - expect(wrapper.find('[data-testid="fee-cycle-feetype-fixed-4"]').exists()).toBe(true) - }) - - it("says when a member was last asked, and never when they have not been", async () => { - const wrapper = await openDialog([ - apiRow({userId: 1, lastAskedOn: "2026-03-04"}), - apiRow({userId: 2, name: "Ben New", lastAskedOn: null}), - ]) - - expect(wrapper.find('[data-testid="fee-cycle-last-asked-1"]').text()).toBe("04/03/2026") - expect(wrapper.find('[data-testid="fee-cycle-last-asked-2"]').text()).toBe("Never") - }) - - it("re-prices a row when its fee type changes, without asking the api again", async () => { - const wrapper = await openDialog([apiRow({userId: 1, feeType: BulkFeeType.FULL_YEAR_FEE, amount: 45})]) - expect(wrapper.find('[data-testid="fee-cycle-amount-1"]').text()).toContain("45.00") - - wrapper.vm.feeTypeSelections[1] = BulkFeeType.ALUMNI_FEE - await settle() - - expect(wrapper.find('[data-testid="fee-cycle-amount-1"]').text()).toContain("10.00") - expect(mockPreviewFeeCycle).toHaveBeenCalledTimes(1) - }) - - it("sends both dates and only the fee types that were changed", async () => { - mockSendFeeCycle.mockResolvedValue({ - data: {paymentRequestsQueued: 1, preNotificationsQueued: 1, excluded: 0}, - }) - const wrapper = await openDialog([ - apiRow({userId: 1, feeType: BulkFeeType.FULL_YEAR_FEE}), - apiRow({userId: 2, name: "Ben Debit", group: FeeCycleGroup.DIRECT_DEBIT}), - ]) - - wrapper.vm.paymentDueDate = "2026-04-01" - wrapper.vm.debitDate = "2026-04-15" - wrapper.vm.feeTypeSelections[2] = BulkFeeType.ALUMNI_FEE - await settle() - await wrapper.find('[data-testid="bulk-action-confirm-btn"]').trigger("click") - await settle() - - expect(mockSendFeeCycle).toHaveBeenCalledWith({ - body: { - contributionPeriodId: 3, - paymentDueDate: "2026-04-01", - debitDate: "2026-04-15", - feeTypeOverrides: {"2": BulkFeeType.ALUMNI_FEE}, - }, - }) - }) - - /** - * The generated client hands a refusal back rather than throwing, so a try/catch would - * report a send that wrote nothing as a success. - */ - it("reports a refused send rather than closing on it", async () => { - mockSendFeeCycle.mockResolvedValue({ - response: {status: 409}, - error: { - errors: [ - { - code: "NonRecipientFeeTypeUserIds", - field: "feeTypeOverrides", - message: "1 of the fee types name members this cycle does not write to.", - values: [4], - }, - ], - }, - }) - const wrapper = await openDialog([apiRow({userId: 1})]) - - wrapper.vm.paymentDueDate = "2026-04-01" - wrapper.vm.debitDate = "2026-04-15" - await settle() - await wrapper.find('[data-testid="bulk-action-confirm-btn"]').trigger("click") - await settle() - - const refusal = wrapper.find('[data-testid="fee-cycle-rejection"]') - expect(refusal.exists()).toBe(true) - expect(refusal.text()).toContain("Nothing was sent") - expect(wrapper.emitted("done")).toBeUndefined() - }) - - it("says so when the cycle cannot be read", async () => { - mockPreviewFeeCycle.mockResolvedValue({data: undefined}) - const wrapper = mount(FeeCycleDialog, {props: {modelValue: true, period}}) - await settle() - - expect(wrapper.find('[data-testid="fee-cycle-load-error"]').exists()).toBe(true) - }) - - it("asks the api for nothing when no period is selected", async () => { - mount(FeeCycleDialog, {props: {modelValue: true, period: null}}) - await settle() - - expect(mockPreviewFeeCycle).not.toHaveBeenCalled() - }) -}) - -/** - * The preview stack already on `main`: `useEmailPreview` holds the state and takes the - * fetch as a closure, `EmailPreviewDialog` renders it, and the recipient chooser goes in - * that dialog's own slot. Nothing new was built for this. - */ -describe("FeeCycleDialog reading an email", () => { - function givenRenderedEmail(overrides: Record = {}) { - mockPreviewFeeCycleEmail.mockResolvedValue({ - data: { - group: FeeCycleGroup.TRANSFER, - feeType: BulkFeeType.FULL_YEAR_FEE, - subject: "Please pay your Blueshell contribution (2025/2026)", - html: "

Amount due: €45,00

", - recipientEmail: "ann@example.com", - recipientName: "Ann Regular", - ...overrides, - }, - }) - } - - async function openWithDates(rows: FeeCycleRowResponse[]) { - const wrapper = await openDialog(rows) - wrapper.vm.paymentDueDate = "2026-04-01" - wrapper.vm.debitDate = "2026-04-15" - await settle() - return wrapper - } - - it("cannot read an email until both dates are given", async () => { - const wrapper = await openDialog([apiRow()]) - - expect( - wrapper.find('[data-testid="fee-cycle-preview-email-btn"]').attributes("disabled"), - ).toBeDefined() - }) - - it("renders the chosen member's email, with the fee type the row shows", async () => { - givenRenderedEmail() - const wrapper = await openWithDates([apiRow({userId: 1, feeType: BulkFeeType.FULL_YEAR_FEE})]) - - await wrapper.find('[data-testid="fee-cycle-preview-email-btn"]').trigger("click") - await settle() - - expect(mockPreviewFeeCycleEmail).toHaveBeenCalledWith({ - query: { - contributionPeriodId: 3, - userId: 1, - paymentDueDate: "2026-04-01", - debitDate: "2026-04-15", - feeType: BulkFeeType.FULL_YEAR_FEE, - }, - }) - expect(wrapper.find('[data-testid="email-preview-subject"]').text()) - .toContain("Please pay your Blueshell contribution") - }) - - it("offers a member from each side of the partition to read as", async () => { - givenRenderedEmail() - const wrapper = await openWithDates([ - apiRow({userId: 1, name: "Ann Transfer", group: FeeCycleGroup.TRANSFER}), - apiRow({userId: 2, name: "Ben Debit", group: FeeCycleGroup.DIRECT_DEBIT}), - ]) - - await wrapper.find('[data-testid="fee-cycle-preview-email-btn"]').trigger("click") - await settle() - - expect(wrapper.vm.previewRecipientId).toBe(1) - expect(wrapper.vm.previewRecipients).toEqual([ - {value: 1, title: "Ann Transfer — Transfer"}, - {value: 2, title: "Ben Debit — Direct debit"}, - ]) - }) - - it("re-renders when a different member is chosen", async () => { - givenRenderedEmail() - const wrapper = await openWithDates([ - apiRow({userId: 1, name: "Ann Transfer", group: FeeCycleGroup.TRANSFER}), - apiRow({userId: 2, name: "Ben Debit", group: FeeCycleGroup.DIRECT_DEBIT}), - ]) - - await wrapper.find('[data-testid="fee-cycle-preview-email-btn"]').trigger("click") - await settle() - expect(mockPreviewFeeCycleEmail).toHaveBeenCalledTimes(1) - - givenRenderedEmail({ - group: FeeCycleGroup.DIRECT_DEBIT, - subject: "Your Blueshell contribution will be collected automatically (2025/2026)", - }) - wrapper.vm.previewRecipientId = 2 - await settle() - - expect(mockPreviewFeeCycleEmail).toHaveBeenCalledTimes(2) - expect(mockPreviewFeeCycleEmail).toHaveBeenLastCalledWith( - expect.objectContaining({query: expect.objectContaining({userId: 2})}), - ) - expect(wrapper.find('[data-testid="email-preview-subject"]').text()) - .toContain("collected automatically") - }) - - it("offers no excluded member to read as, because they get no email", async () => { - givenRenderedEmail() - const wrapper = await openWithDates([ - apiRow({userId: 1, name: "Ann Transfer"}), - apiRow({ - userId: 4, - name: "Cara Honorary", - disposition: BulkRowDisposition.EXCLUDED, - reason: BulkRowReason.HONORARY, - feeType: null, - amount: null, - }), - ]) - - await wrapper.find('[data-testid="fee-cycle-preview-email-btn"]').trigger("click") - await settle() - - expect(wrapper.vm.previewRecipients).toEqual([{value: 1, title: "Ann Transfer — Transfer"}]) - }) - - it("sends nothing when an email is read", async () => { - givenRenderedEmail() - const wrapper = await openWithDates([apiRow()]) - - await wrapper.find('[data-testid="fee-cycle-preview-email-btn"]').trigger("click") - await settle() - - expect(mockSendFeeCycle).not.toHaveBeenCalled() - }) -}) diff --git a/services/frontend/tests/unit/components/common/modals/bulk/PaidStatus.test.ts b/services/frontend/tests/unit/components/common/modals/bulk/PaidStatus.test.ts index a231b0aba..43f66bbd7 100644 --- a/services/frontend/tests/unit/components/common/modals/bulk/PaidStatus.test.ts +++ b/services/frontend/tests/unit/components/common/modals/bulk/PaidStatus.test.ts @@ -98,7 +98,7 @@ describe("PaidStatusDialog (Mark as paid)", () => { expect(dispositionChip.text()).toContain("Skipped") const noteCell = wrapper.find('[data-testid="bulk-preview-note-3"]') - expect(noteCell.text()).toContain("Honorary") + expect(noteCell.text()).toContain("Owes no contribution") }) it("displays member type and member-since date in preview", async () => { @@ -294,7 +294,7 @@ describe("PaidStatusDialog (Mark as unpaid)", () => { expect(dispositionChip.text()).toContain("Skipped") const noteCell = wrapper.find('[data-testid="bulk-preview-note-3"]') - expect(noteCell.text()).toContain("Honorary") + expect(noteCell.text()).toContain("Owes no contribution") }) it("displays counts summary with paid and unpaid", async () => { diff --git a/services/frontend/tests/unit/components/common/modals/bulk/PaymentEmailWizard.test.ts b/services/frontend/tests/unit/components/common/modals/bulk/PaymentEmailWizard.test.ts new file mode 100644 index 000000000..e54f58c1d --- /dev/null +++ b/services/frontend/tests/unit/components/common/modals/bulk/PaymentEmailWizard.test.ts @@ -0,0 +1,755 @@ +import {describe, expect, it, vi} from "vitest" +import {mount, type VueWrapper} from "@vue/test-utils" +import PaymentEmailWizard from "@/components/common/modals/bulk/paymentEmail/PaymentEmailWizard.vue" +import { + BulkFeeType, + BulkRowDisposition, + BulkRowReason, + ContributionEmailKind, + MemberType, + type BulkContributionEmailRowResponse, + type ContributionPeriodResponse, +} from "@/services/api" +import {settle} from "../../../../helpers/testUtils" + +const {mockPreview, mockSend, mockReadEmail} = vi.hoisted(() => ({ + mockPreview: vi.fn(), + mockSend: vi.fn(), + mockReadEmail: vi.fn(), +})) +vi.mock("@/services/api/blueshell/sdk.gen", () => ({ + previewBulkContributionEmail: mockPreview, + sendPaymentEmails: mockSend, + readContributionEmail: mockReadEmail, +})) + +/** + * Vuetify is not installed in the unit environment, so its inputs would render as unresolved + * elements that emit nothing. These stubs are the smallest thing that behaves like the real + * control from the point of view of the wizard: a value in, an event out. + */ +const stubs = { + // The only Vuetify behaviour the wizard leans on: a dialog shows nothing while closed, + // which is how the confirmation being dismissed is visible at all. + VDialog: { + props: ["modelValue"], + template: `
`, + }, + VCheckbox: { + props: ["modelValue"], + emits: ["update:modelValue"], + template: ``, + }, + VSelect: { + props: ["modelValue", "items"], + emits: ["update:modelValue"], + template: ``, + }, + VTextField: { + props: ["modelValue", "errorMessages", "hint"], + emits: ["update:modelValue"], + template: `
+ {{ errorMessages }} + {{ hint }}
`, + }, +} + +const period: ContributionPeriodResponse = { + id: 3, + startDate: "2025-09-01", + endDate: "2026-08-31", + halfYearCutoffDate: "2026-02-01", + halfYearFee: 25, + fullYearFee: 45, + alumniFee: 10, + version: 0, + createdAt: "2025-09-01T00:00:00Z", + updatedAt: "2025-09-01T00:00:00Z", +} + +/** Every date the wizard accepts must be after today, so the fixtures move with the clock. */ +const soon = new Date(Date.now() + 30 * 24 * 3_600_000).toISOString().slice(0, 10) +const alsoSoon = new Date(Date.now() + 45 * 24 * 3_600_000).toISOString().slice(0, 10) + +function apiRow( + overrides: Partial = {}, +): BulkContributionEmailRowResponse { + return { + userId: 1, + name: "Ann Regular", + memberType: MemberType.REGULAR, + memberSince: "2025-09-01", + disposition: BulkRowDisposition.INCLUDED, + reason: null, + defaultKind: ContributionEmailKind.REMINDER, + feeType: BulkFeeType.FULL_YEAR_FEE, + amount: 45, + lastRemindedOn: null, + lastNotifiedOn: null, + ...overrides, + } +} + +type Wizard = VueWrapper> + +async function openWizard( + rows: BulkContributionEmailRowResponse[], + unknownUserIds: number[] = [], +): Promise { + mockPreview.mockResolvedValue({data: {contributionPeriodId: period.id, rows, unknownUserIds}}) + const wrapper = mount(PaymentEmailWizard, { + global: {stubs}, + props: { + modelValue: true, + period, + userIds: [...rows.map((row) => row.userId), ...unknownUserIds], + }, + }) + await settle() + return wrapper +} + +async function next(wrapper: Wizard) { + await wrapper.find('[data-testid="payment-emails-next-btn"]').trigger("click") + await settle() +} + +async function back(wrapper: Wizard) { + await wrapper.find('[data-testid="payment-emails-back-btn"]').trigger("click") + await settle() +} + +async function tick(wrapper: Wizard, userId: number) { + await wrapper.find(`[data-testid="payment-emails-send-to-${userId}"]`).trigger("change") + await settle() +} + +async function typeDate(wrapper: Wizard, testid: string, value: string) { + const field = wrapper.find(`[data-testid="${testid}"] input`) + await field.setValue(value) + await settle() +} + +async function chooseKind(wrapper: Wizard, userId: number, kind: ContributionEmailKind) { + const select = wrapper.find(`[data-testid="payment-emails-kind-${userId}"]`) + await select.setValue(kind) + await settle() +} + +async function chooseFee(wrapper: Wizard, userId: number, fee: BulkFeeType) { + const select = wrapper.find(`[data-testid="payment-emails-feetype-${userId}"]`) + await select.setValue(fee) + await settle() +} + +/** Step 3's Send opens the summary; the summary sends. */ +async function sendFromSummary(wrapper: Wizard) { + await next(wrapper) + await wrapper.find('[data-testid="payment-emails-confirm-send-btn"]').trigger("click") + await settle() +} + +describe("PaymentEmailWizard step 1, the members", () => { + it("asks the api what the selection would be sent", async () => { + await openWizard([apiRow({userId: 1}), apiRow({userId: 2, name: "Ben Debit"})]) + + expect(mockPreview).toHaveBeenCalledWith({ + body: {contributionPeriodId: 3, userIds: [1, 2]}, + }) + }) + + it("ticks a member the api would write to and leaves a warned one unticked", async () => { + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({ + userId: 2, + name: "Ben Paid", + disposition: BulkRowDisposition.WARNING, + reason: BulkRowReason.ALREADY_PAID, + }), + ]) + + expect(wrapper.find('[data-testid="payment-emails-send-to-1"]').element) + .toHaveProperty("checked", true) + expect(wrapper.find('[data-testid="payment-emails-send-to-2"]').element) + .toHaveProperty("checked", false) + }) + + it("offers no box at all to a member it cannot email, and says why", async () => { + const wrapper = await openWizard([ + apiRow({ + userId: 4, + name: "Cara Honorary", + memberType: MemberType.HONORARY, + disposition: BulkRowDisposition.EXCLUDED, + reason: BulkRowReason.HONORARY, + feeType: null, + amount: null, + }), + ]) + + expect(wrapper.find('[data-testid="payment-emails-send-to-4"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="payment-emails-reason-4"]').text()) + .toBe("Owes no contribution") + }) + + it("counts the members it cannot email in the words the treasurer reads", async () => { + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({ + userId: 4, + name: "Cara Honorary", + disposition: BulkRowDisposition.EXCLUDED, + reason: BulkRowReason.HONORARY, + feeType: null, + amount: null, + }), + ]) + + expect(wrapper.find('[data-testid="payment-emails-count-excluded"]').text()) + .toContain("1 cannot be emailed") + }) + + it("counts an id that is no longer a user rather than losing it", async () => { + const wrapper = await openWizard([apiRow({userId: 1})], [77]) + + expect(wrapper.find('[data-testid="payment-emails-count-unknown"]').text()) + .toContain("1 no longer exists") + }) + + it("drops an unticked member from the batch", async () => { + const wrapper = await openWizard([apiRow({userId: 1}), apiRow({userId: 2, name: "Ben Debit"})]) + + await tick(wrapper, 2) + await next(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-fee-row-1"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-fee-row-2"]').exists()).toBe(false) + }) + + it("goes no further with nobody ticked", async () => { + const wrapper = await openWizard([apiRow({userId: 1})]) + + await tick(wrapper, 1) + await next(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-members-table"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-fees-table"]').exists()).toBe(false) + }) +}) + +describe("PaymentEmailWizard step 2, the fees and emails", () => { + it("shows only the members still ticked", async () => { + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({ + userId: 2, + name: "Ben Paid", + disposition: BulkRowDisposition.WARNING, + reason: BulkRowReason.ALREADY_PAID, + }), + ]) + + await next(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-fee-row-1"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-fee-row-2"]').exists()).toBe(false) + }) + + it("warns by name when a member is moved onto the other email", async () => { + const wrapper = await openWizard([ + apiRow({userId: 2, name: "Ben Debit", defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}), + ]) + await next(wrapper) + expect(wrapper.find('[data-testid="payment-emails-kind-warning"]').exists()).toBe(false) + + await chooseKind(wrapper, 2, ContributionEmailKind.REMINDER) + + const warning = wrapper.find('[data-testid="payment-emails-kind-warning"]') + expect(warning.text()).toContain("Ben Debit") + expect(warning.text()).toContain("pay twice") + }) + + it("warns separately, by name, when a member's fee type is changed", async () => { + const wrapper = await openWizard([apiRow({userId: 1, feeType: BulkFeeType.FULL_YEAR_FEE})]) + await next(wrapper) + expect(wrapper.find('[data-testid="payment-emails-fee-warning"]').exists()).toBe(false) + + await chooseFee(wrapper, 1, BulkFeeType.ALUMNI_FEE) + + expect(wrapper.find('[data-testid="payment-emails-fee-warning"]').text()) + .toContain("Ann Regular") + expect(wrapper.find('[data-testid="payment-emails-kind-warning"]').exists()).toBe(false) + }) + + it("re-prices a row from the period, without asking the api again", async () => { + const wrapper = await openWizard([apiRow({feeType: BulkFeeType.FULL_YEAR_FEE, amount: 45})]) + await next(wrapper) + expect(wrapper.find('[data-testid="payment-emails-amount-1"]').text()).toContain("45.00") + + await chooseFee(wrapper, 1, BulkFeeType.ALUMNI_FEE) + + expect(wrapper.find('[data-testid="payment-emails-amount-1"]').text()).toContain("10.00") + expect(mockPreview).toHaveBeenCalledTimes(1) + }) + + // A member moved onto direct debit has been asked by transfer, never pre-notified. + it("reads last sent for the email the row is set to", async () => { + const wrapper = await openWizard([ + apiRow({ + userId: 2, + name: "Ben Moved", + defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION, + lastRemindedOn: "2026-03-04", + }), + ]) + await next(wrapper) + expect(wrapper.find('[data-testid="payment-emails-last-sent-2"]').text()).toBe("Never") + + await chooseKind(wrapper, 2, ContributionEmailKind.REMINDER) + + expect(wrapper.find('[data-testid="payment-emails-last-sent-2"]').text()).toBe("04/03/2026") + }) +}) + +describe("PaymentEmailWizard step 3, what will be sent", () => { + it("lists each recipient with the email and the amount they get", async () => { + const wrapper = await openWizard([apiRow({userId: 1, feeType: BulkFeeType.FULL_YEAR_FEE})]) + await next(wrapper) + await next(wrapper) + + const recipient = wrapper.find('[data-testid="payment-emails-recipient-1"]') + expect(recipient.text()).toContain("Ann Regular") + expect(recipient.text()).toContain("Contribution reminder") + expect(recipient.text()).toContain("Full-year fee") + expect(recipient.text()).toContain("45.00") + }) + + it("says a date nobody needs is optional", async () => { + const wrapper = await openWizard([apiRow({userId: 1})]) + await next(wrapper) + await next(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-debit-date"]').text()) + .toContain("Nobody here is on direct debit") + }) + + it("marks a date the api would refuse", async () => { + const wrapper = await openWizard([apiRow({userId: 1})]) + await next(wrapper) + await next(wrapper) + + await typeDate(wrapper, "payment-emails-payment-due-date", "2030-01-01") + + expect(wrapper.find('[data-testid="payment-emails-payment-due-date"] .field-error').text()) + .toContain("The date must fall between 01/09/2025 and 30/11/2026.") + }) + + it("renders one member's own email, with the date and fee type their row shows", async () => { + mockReadEmail.mockResolvedValue({ + data: { + kind: ContributionEmailKind.REMINDER, + subject: "Please pay your Blueshell contribution", + html: "

Amount due

", + recipientEmail: "ann@example.com", + recipientName: "Ann Regular", + }, + }) + const wrapper = await openWizard([apiRow({userId: 1})]) + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + + await wrapper.find('[data-testid="payment-emails-preview-1"]').trigger("click") + await settle() + + expect(mockReadEmail).toHaveBeenCalledWith({ + query: { + kind: ContributionEmailKind.REMINDER, + contributionPeriodId: 3, + userId: 1, + date: soon, + feeType: BulkFeeType.FULL_YEAR_FEE, + }, + }) + expect(mockSend).not.toHaveBeenCalled() + }) +}) + +describe("PaymentEmailWizard moving between steps", () => { + it("keeps every choice made on a later step when the member list is revisited", async () => { + const wrapper = await openWizard([apiRow({userId: 1}), apiRow({userId: 2, name: "Ben Debit"})]) + await next(wrapper) + await chooseFee(wrapper, 2, BulkFeeType.ALUMNI_FEE) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + + await back(wrapper) + await back(wrapper) + expect(wrapper.find('[data-testid="payment-emails-members-table"]').exists()).toBe(true) + + await next(wrapper) + expect(wrapper.find('[data-testid="payment-emails-amount-2"]').text()).toContain("10.00") + await next(wrapper) + expect( + wrapper.find('[data-testid="payment-emails-payment-due-date"] input').element, + ).toHaveProperty("value", soon) + }) +}) + +describe("PaymentEmailWizard sending", () => { + it("posts the ticked members, the dates they need and only what was changed", async () => { + mockSend.mockResolvedValue({data: {remindersSent: 2, incassoNotificationsSent: 0}}) + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({userId: 2, name: "Ben Debit", defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}), + apiRow({userId: 3, name: "Cara Dropped"}), + ]) + + await tick(wrapper, 3) + await next(wrapper) + await chooseKind(wrapper, 2, ContributionEmailKind.REMINDER) + await chooseFee(wrapper, 2, BulkFeeType.ALUMNI_FEE) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await sendFromSummary(wrapper) + + expect(mockSend).toHaveBeenCalledWith({ + body: { + contributionPeriodId: 3, + userIds: [1, 2], + forciblyIncludedUserIds: [], + kindOverrides: {"2": ContributionEmailKind.REMINDER}, + paymentDueDate: soon, + debitDate: undefined, + feeTypeOverrides: {"2": BulkFeeType.ALUMNI_FEE}, + }, + }) + }) + + it("names the warned members ticked back in, so the api overrules its own warning", async () => { + mockSend.mockResolvedValue({data: {remindersSent: 2, incassoNotificationsSent: 0}}) + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({ + userId: 2, + name: "Ben Paid", + disposition: BulkRowDisposition.WARNING, + reason: BulkRowReason.ALREADY_PAID, + }), + apiRow({ + userId: 3, + name: "Cara Honorary", + disposition: BulkRowDisposition.EXCLUDED, + reason: BulkRowReason.HONORARY, + feeType: null, + amount: null, + }), + ]) + + await tick(wrapper, 2) + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await sendFromSummary(wrapper) + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({userIds: [1, 2], forciblyIncludedUserIds: [2]}), + }), + ) + }) + + it("sends both dates when the batch needs both", async () => { + mockSend.mockResolvedValue({data: {remindersSent: 1, incassoNotificationsSent: 1}}) + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({userId: 2, name: "Ben Debit", defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}), + ]) + + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await typeDate(wrapper, "payment-emails-debit-date", alsoSoon) + await sendFromSummary(wrapper) + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({paymentDueDate: soon, debitDate: alsoSoon}), + }), + ) + }) + + it("opens the summary rather than sending, and counts every override in it", async () => { + const wrapper = await openWizard([ + apiRow({userId: 1, lastRemindedOn: "2026-01-05"}), + apiRow({ + userId: 2, + name: "Ben Paid", + disposition: BulkRowDisposition.WARNING, + reason: BulkRowReason.ALREADY_PAID, + }), + ]) + + await tick(wrapper, 2) + await next(wrapper) + await chooseFee(wrapper, 1, BulkFeeType.ALUMNI_FEE) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await next(wrapper) + + expect(mockSend).not.toHaveBeenCalled() + const overrides = wrapper.find('[data-testid="payment-emails-confirm-overrides"]') + expect(overrides.text()).toContain("1 member is included despite a warning") + expect(overrides.text()).toContain("1 member is charged a fee that does not apply to them") + expect(overrides.text()).toContain("1 member has had this same email for this period before") + expect(wrapper.find('[data-testid="payment-emails-confirm-summary"]').text()) + .toContain("cannot be undone") + }) + + it("lists an email already sent without calling it an override", async () => { + const wrapper = await openWizard([apiRow({userId: 1, lastRemindedOn: "2026-01-05"})]) + + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await next(wrapper) + + const overrides = wrapper.find('[data-testid="payment-emails-confirm-overrides"]') + expect(overrides.text()).toContain("1 member has had this same email for this period before") + expect(wrapper.find('[data-testid="payment-emails-confirm-forced"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="payment-emails-confirm-switched"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="payment-emails-confirm-recharged"]').exists()).toBe(false) + }) + + it("does not block on a date the request leaves out", async () => { + mockSend.mockResolvedValue({data: {remindersSent: 1, incassoNotificationsSent: 0}}) + const wrapper = await openWizard([apiRow({userId: 1})]) + + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + // Nobody is on direct debit, so this one is stripped from the request. + await typeDate(wrapper, "payment-emails-debit-date", "1999-01-01") + await sendFromSummary(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-debit-date"] .field-error').text()).toBe("") + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({body: expect.objectContaining({debitDate: undefined})}), + ) + }) + + it("backing out of the summary sends nothing and keeps the batch intact", async () => { + const wrapper = await openWizard([apiRow({userId: 1})]) + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await next(wrapper) + + await wrapper.find('[data-testid="payment-emails-confirm-back-btn"]').trigger("click") + await settle() + + expect(mockSend).not.toHaveBeenCalled() + expect(wrapper.emitted("done")).toBeUndefined() + expect(wrapper.find('[data-testid="payment-emails-recipient-1"]').exists()).toBe(true) + }) + + it("says so when the selection cannot be read", async () => { + mockPreview.mockResolvedValue({data: undefined}) + const wrapper = mount(PaymentEmailWizard, { + global: {stubs}, + props: {modelValue: true, period, userIds: [1]}, + }) + await settle() + + expect(wrapper.find('[data-testid="payment-emails-load-error"]').exists()).toBe(true) + }) + + it("asks the api for nothing without a selection or a period", async () => { + mount(PaymentEmailWizard, {global: {stubs}, props: {modelValue: true, period, userIds: []}}) + mount(PaymentEmailWizard, {global: {stubs}, props: {modelValue: true, period: null, userIds: [1]}}) + await settle() + + expect(mockPreview).not.toHaveBeenCalled() + }) +}) + +/** + * The api refuses whole, naming the request field at fault. The wizard's job is to put the + * treasurer back where that field is edited, with the rows or the input it named marked. + */ +describe("PaymentEmailWizard a refused send", () => { + function refusal(status: number, errors: unknown[]) { + return {response: {status}, error: {errors}} + } + + async function sendTwoMembers(wrapper: Wizard) { + await next(wrapper) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await sendFromSummary(wrapper) + } + + const twoMembers = () => [apiRow({userId: 1}), apiRow({userId: 2, name: "Ben Gone"})] + + it("lands on the members step with the rows the api named marked", async () => { + mockSend.mockResolvedValue(refusal(409, [{ + code: "UnknownUserIds", + field: "userIds", + message: "1 of the selected users no longer exist.", + values: [2], + }])) + const wrapper = await openWizard(twoMembers()) + + await sendTwoMembers(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-members-table"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-refusal-2"]').text()) + .toContain("no longer exist") + expect(wrapper.find('[data-testid="payment-emails-refusal-1"]').exists()).toBe(false) + }) + + it("lands on the fees step when the refusal is about an override", async () => { + mockSend.mockResolvedValue(refusal(409, [{ + code: "NonRecipientFeeTypeUserIds", + field: "feeTypeOverrides", + message: "1 of the fee types name members this send does not write to.", + values: [2], + }])) + const wrapper = await openWizard(twoMembers()) + + await sendTwoMembers(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-fees-table"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-members-table"]').exists()).toBe(false) + }) + + it("lands on the last step with the date the api refused flagged", async () => { + mockSend.mockResolvedValue(refusal(400, [{ + code: "DateOutsideContributionPeriod", + field: "paymentDueDate", + message: "A date must fall within the contribution period, or shortly after it ends.", + }])) + const wrapper = await openWizard([apiRow({userId: 1})]) + + await sendTwoMembers(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-recipient-1"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-payment-due-date"] .field-error').text()) + .toContain("must fall within the contribution period") + }) + + it("clears a refused date once that date is changed", async () => { + mockSend.mockResolvedValue(refusal(400, [{ + code: "DateRequired", + field: "paymentDueDate", + message: "A date is required: somebody in this batch gets an email that states one.", + }])) + const wrapper = await openWizard([apiRow({userId: 1})]) + await sendTwoMembers(wrapper) + + await typeDate(wrapper, "payment-emails-payment-due-date", alsoSoon) + + expect(wrapper.find('[data-testid="payment-emails-payment-due-date"] .field-error').text()) + .toBe("") + }) + + it("goes back to the earliest step a refusal names, not the last", async () => { + mockSend.mockResolvedValue(refusal(409, [ + { + code: "NonRecipientFeeTypeUserIds", + field: "feeTypeOverrides", + message: "1 of the fee types name members this send does not write to.", + values: [2], + }, + {code: "DuplicateUserIds", field: "userIds", message: "", values: [1]}, + ])) + const wrapper = await openWizard(twoMembers()) + + await sendTwoMembers(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-members-table"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="payment-emails-refusal-1"]').text()) + .toBe("The selection names the same member more than once.") + }) + + it("closes the confirmation and reports that nothing was sent", async () => { + mockSend.mockResolvedValue(refusal(409, [ + {code: "DuplicateUserIds", field: "userIds", message: "", values: [1]}, + ])) + const wrapper = await openWizard(twoMembers()) + + await sendTwoMembers(wrapper) + + expect(wrapper.find('[data-testid="payment-emails-confirm-summary"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="payment-emails-rejection"]').text()) + .toContain("Nothing was sent") + expect(wrapper.emitted("done")).toBeUndefined() + }) + + it("keeps the choices a conflict did not contradict, and drops the ones it did", async () => { + mockSend.mockResolvedValue(refusal(409, [{ + code: "UnknownUserIds", + field: "userIds", + message: "1 of the selected users no longer exist.", + values: [2], + }])) + const wrapper = await openWizard([ + apiRow({userId: 1}), + apiRow({userId: 2, name: "Ben Gone"}), + apiRow({ + userId: 3, + name: "Cara Paid", + disposition: BulkRowDisposition.WARNING, + reason: BulkRowReason.ALREADY_PAID, + }), + ]) + await tick(wrapper, 3) + await next(wrapper) + await chooseFee(wrapper, 1, BulkFeeType.ALUMNI_FEE) + await chooseFee(wrapper, 2, BulkFeeType.ALUMNI_FEE) + await next(wrapper) + await typeDate(wrapper, "payment-emails-payment-due-date", soon) + await sendFromSummary(wrapper) + + mockSend.mockResolvedValue({data: {remindersSent: 3, incassoNotificationsSent: 0}}) + await next(wrapper) + await next(wrapper) + await sendFromSummary(wrapper) + + expect(mockSend).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + userIds: [1, 2, 3], + forciblyIncludedUserIds: [3], + // Ann and Cara keep what was chosen; Ben was named by the refusal and does not. + feeTypeOverrides: {"1": BulkFeeType.ALUMNI_FEE}, + }), + }), + ) + }) + + // A conflict means the plan the send read is not the one the table shows. + it("re-reads the plan on a conflict, and leaves it alone on a bad field", async () => { + mockSend.mockResolvedValue(refusal(409, [ + {code: "DuplicateUserIds", field: "userIds", message: "", values: [1]}, + ])) + const conflicted = await openWizard(twoMembers()) + await sendTwoMembers(conflicted) + expect(mockPreview).toHaveBeenCalledTimes(2) + + mockPreview.mockClear() + mockSend.mockResolvedValue(refusal(400, [ + {code: "DateRequired", field: "debitDate", message: ""}, + ])) + const refused = await openWizard([apiRow({userId: 1})]) + await sendTwoMembers(refused) + + expect(mockPreview).toHaveBeenCalledTimes(1) + }) +}) diff --git a/services/frontend/tests/unit/domains/cohorts/composables/useTargetOverview.test.ts b/services/frontend/tests/unit/domains/cohorts/composables/useTargetOverview.test.ts index 1d4ba7922..6a45f2cf1 100644 --- a/services/frontend/tests/unit/domains/cohorts/composables/useTargetOverview.test.ts +++ b/services/frontend/tests/unit/domains/cohorts/composables/useTargetOverview.test.ts @@ -259,6 +259,7 @@ describe("useTargetOverview", () => { namedUserIds: [], namedRefs: ["1"], requiresReload: true, + status: 409, }, }) diff --git a/services/frontend/tests/unit/utils/bulkRejection.test.ts b/services/frontend/tests/unit/utils/bulkRejection.test.ts index 316d93ae7..592f8b649 100644 --- a/services/frontend/tests/unit/utils/bulkRejection.test.ts +++ b/services/frontend/tests/unit/utils/bulkRejection.test.ts @@ -75,13 +75,60 @@ describe("parseBulkRejection", () => { expect(rejection?.requiresReload).toBe(true) }) - it("returns null for a status that is not a refused selection", () => { - expect(parseBulkRejection({response: {status: 400}, error: {errors: []}})).toBeNull() - expect(parseBulkRejection({response: {status: 500}, error: {errors: []}})).toBeNull() + it("returns null for a status that is not a refusal", () => { + const errors = [{field: "userIds", code: BulkRejectionCode.unknownUsers, message: "", values: [1]}] + expect(parseBulkRejection({response: {status: 500}, error: {errors}})).toBeNull() + expect(parseBulkRejection({response: {status: 200}, error: {errors}})).toBeNull() }) - it("returns null when a 409 carries no usable reasons", () => { + it("returns null when a refusal carries no usable reasons", () => { expect(parseBulkRejection(refusal([]))).toBeNull() expect(parseBulkRejection({response: {status: 409}, error: "boom"})).toBeNull() }) + + // Bean validation and the rules above it answer 400 in the same envelope as the 409. + it("reads a field refused with a 400, and says which status it came with", () => { + const rejection = parseBulkRejection({ + response: {status: 400}, + error: { + errors: [{ + objectName: "SendPaymentEmailsRequest", + field: "paymentDueDate", + code: BulkRejectionCode.dateOutsidePeriod, + message: "A date must fall within the contribution period, or shortly after it ends.", + }], + }, + }) + + expect(rejection?.status).toBe(400) + expect(rejection?.reasons[0]?.field).toBe("paymentDueDate") + expect(rejection?.namedUserIds).toEqual([]) + }) + + it("composes the sentence for a new code rather than repeating the api's", () => { + const rejection = parseBulkRejection( + refusal([{ + field: "forciblyIncludedUserIds", + code: BulkRejectionCode.nonRecipientForced, + message: "whatever the api happened to write", + values: [3], + }]), + ) + + expect(rejection?.reasons[0]?.message) + .toBe("Some of the members ticked back in are ones this send does not write to.") + }) + + it("keeps the api's own sentence for a code that composes one", () => { + const rejection = parseBulkRejection( + refusal([{ + field: "userIds", + code: BulkRejectionCode.unknownUsers, + message: "2 of the selected users no longer exist.", + values: [8, 9], + }]), + ) + + expect(rejection?.reasons[0]?.message).toBe("2 of the selected users no longer exist.") + }) }) diff --git a/services/frontend/tests/unit/utils/contributionEmail.test.ts b/services/frontend/tests/unit/utils/contributionEmail.test.ts new file mode 100644 index 000000000..65c4f034f --- /dev/null +++ b/services/frontend/tests/unit/utils/contributionEmail.test.ts @@ -0,0 +1,382 @@ +import {describe, expect, it} from "vitest" +import { + BulkFeeType, + BulkRowDisposition, + BulkRowReason, + ContributionEmailKind, + MemberType, +} from "@/services/api" +import type {BulkContributionEmailRowResponse} from "@/services/api" +import type {BulkRow} from "@/utils/bulkRow" +import { + changedFeeTypes, + changedKinds, + countByKind, + forcedUserIds, + isReCharged, + isSwitched, + kindFor, + lastSentLabel, + lastSentOn, + paymentDateProblem, + periodDateWindow, + reapplyChoices, + seedChoices, + seedSendTo, + summarise, + switchedNote, + toBulkRow, + toBulkRows, + willSend, +} from "@/utils/contributionEmail" + +function apiRow( + overrides: Partial = {}, +): BulkContributionEmailRowResponse { + return { + userId: 1, + name: "Ann Regular", + memberType: MemberType.REGULAR, + memberSince: "2025-09-01", + disposition: BulkRowDisposition.INCLUDED, + reason: null, + defaultKind: ContributionEmailKind.REMINDER, + feeType: BulkFeeType.FULL_YEAR_FEE, + amount: 45, + lastRemindedOn: null, + lastNotifiedOn: null, + ...overrides, + } +} + +function row(overrides: Partial = {}): BulkRow { + return {...toBulkRow(apiRow()), ...overrides} +} + +describe("toBulkRow", () => { + it("carries the member, their disposition, their email and what they owe", () => { + const mapped = toBulkRow( + apiRow({ + userId: 7, + amount: 25, + feeType: BulkFeeType.HALF_YEAR_FEE, + defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION, + }), + ) + + expect(mapped).toMatchObject({ + userId: 7, + name: "Ann Regular", + memberType: MemberType.REGULAR, + memberSince: "2025-09-01", + disposition: BulkRowDisposition.INCLUDED, + defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION, + amount: 25, + recommendedFeeType: BulkFeeType.HALF_YEAR_FEE, + }) + }) + + // The client models absence as null; the row model uses undefined. + it("turns the api's nulls into absent fields", () => { + const mapped = toBulkRow(apiRow({reason: null, feeType: null, amount: null, lastRemindedOn: null})) + + expect(mapped.reason).toBeUndefined() + expect(mapped.recommendedFeeType).toBeUndefined() + expect(mapped.lastRemindedOn).toBeUndefined() + expect(mapped.amount).toBeNull() + }) + + it("maps every row it is given", () => { + expect(toBulkRows([apiRow({userId: 1}), apiRow({userId: 2})]).map((r) => r.userId)).toEqual([1, 2]) + }) +}) + +describe("kindFor and isSwitched", () => { + it("uses the flag's choice until the treasurer picks another", () => { + const debit = row({userId: 2, defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}) + + expect(kindFor(debit, {})).toBe(ContributionEmailKind.INCASSO_NOTIFICATION) + expect(kindFor(debit, {2: ContributionEmailKind.REMINDER})).toBe(ContributionEmailKind.REMINDER) + }) + + it("flags a row only once it differs from the flag's choice", () => { + const debit = row({userId: 2, defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}) + + expect(isSwitched(debit, {2: ContributionEmailKind.INCASSO_NOTIFICATION})).toBe(false) + expect(isSwitched(debit, {2: ContributionEmailKind.REMINDER})).toBe(true) + }) + + it("names the flag the switch contradicts", () => { + expect(switchedNote(row({defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}))) + .toContain("Pays by direct debit") + expect(switchedNote(row({defaultKind: ContributionEmailKind.REMINDER}))) + .toContain("No direct-debit mandate") + }) +}) + +describe("lastSentOn", () => { + // A member moved onto direct debit has been asked by transfer and never pre-notified. + it("reads the date for the email the row is set to", () => { + const moved = row({ + userId: 2, + defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION, + lastRemindedOn: "2026-03-04", + lastNotifiedOn: undefined, + }) + + expect(lastSentOn(moved, {})).toBeUndefined() + expect(lastSentOn(moved, {2: ContributionEmailKind.REMINDER})).toBe("2026-03-04") + }) + + it("reads a date the way every other bulk dialog does, and says never for none", () => { + expect(lastSentLabel("2026-03-04")).toBe("04/03/2026") + expect(lastSentLabel(undefined)).toBe("Never") + }) +}) + +describe("the selection", () => { + const rows = [ + row({userId: 1, disposition: "INCLUDED"}), + row({userId: 2, disposition: "WARNING", reason: BulkRowReason.ALREADY_PAID}), + row({userId: 3, disposition: "EXCLUDED", reason: BulkRowReason.HONORARY}), + ] + + it("starts a warned member unticked and gives an unreachable one no box at all", () => { + expect(seedSendTo(rows)).toEqual({1: true, 2: false}) + }) + + it("sends to a row only while it is ticked", () => { + expect(willSend(rows[0]!, {1: true})).toBe(true) + expect(willSend(rows[0]!, {1: false})).toBe(false) + expect(willSend(rows[1]!, {2: true})).toBe(true) + }) + + it("never sends to a member it cannot reach, whatever is ticked", () => { + expect(willSend(rows[2]!, {3: true})).toBe(false) + }) + + it("names the warned rows the treasurer ticked back in", () => { + expect(forcedUserIds(rows, {1: true, 2: true, 3: true})).toEqual([2]) + expect(forcedUserIds(rows, {1: true})).toEqual([]) + }) +}) + +describe("isReCharged", () => { + const priced = row({userId: 1, recommendedFeeType: BulkFeeType.FULL_YEAR_FEE}) + + it("is false while the row is on the fee its membership works out to", () => { + expect(isReCharged(priced, {})).toBe(false) + expect(isReCharged(priced, {1: BulkFeeType.FULL_YEAR_FEE})).toBe(false) + }) + + it("is true once the treasurer picks another fee", () => { + expect(isReCharged(priced, {1: BulkFeeType.ALUMNI_FEE})).toBe(true) + }) +}) + +describe("carrying choices onto a plan that was read again", () => { + const rows = [ + row({userId: 1, disposition: "INCLUDED"}), + row({userId: 2, disposition: "INCLUDED"}), + row({userId: 3, disposition: "WARNING", reason: BulkRowReason.ALREADY_PAID}), + row({userId: 4, disposition: "EXCLUDED", reason: BulkRowReason.HONORARY}), + ] + + const made = { + sendTo: {1: false, 2: true, 3: true}, + fees: {1: BulkFeeType.ALUMNI_FEE, 2: BulkFeeType.ALUMNI_FEE, 3: BulkFeeType.ALUMNI_FEE}, + kinds: {2: ContributionEmailKind.INCASSO_NOTIFICATION}, + } + + it("seeds a plan the way the api proposes it", () => { + expect(seedChoices(rows).sendTo).toEqual({1: true, 2: true, 3: false}) + expect(seedChoices(rows).fees[1]).toBe(BulkFeeType.FULL_YEAR_FEE) + expect(seedChoices(rows).kinds[1]).toBe(ContributionEmailKind.REMINDER) + }) + + it("puts back every choice the refusal did not name", () => { + const next = reapplyChoices(rows, seedChoices(rows), made, []) + + expect(next.sendTo).toEqual({1: false, 2: true, 3: true}) + expect(next.fees[2]).toBe(BulkFeeType.ALUMNI_FEE) + expect(next.kinds[2]).toBe(ContributionEmailKind.INCASSO_NOTIFICATION) + }) + + it("gives a member the refusal named the new plan's answer instead", () => { + const next = reapplyChoices(rows, seedChoices(rows), made, [2]) + + expect(next.sendTo[2]).toBe(true) + expect(next.fees[2]).toBe(BulkFeeType.FULL_YEAR_FEE) + expect(next.kinds[2]).toBe(ContributionEmailKind.REMINDER) + }) + + it("leaves a member the plan can no longer reach out of the selection", () => { + const next = reapplyChoices(rows, seedChoices(rows), {...made, sendTo: {4: true}}, []) + + expect(next.sendTo[4]).toBeUndefined() + }) + + it("forgets a choice for somebody the new plan does not list", () => { + const next = reapplyChoices([rows[0]!], seedChoices([rows[0]!]), made, []) + + expect(next.fees[2]).toBeUndefined() + expect(next.sendTo[2]).toBeUndefined() + }) +}) + +describe("paymentDateProblem", () => { + const period = {startDate: "2025-09-01", endDate: "2026-08-31"} + const today = "2026-03-01" + + it("accepts a date inside the period", () => { + expect(paymentDateProblem("2026-04-01", period, today)).toBeNull() + }) + + it("says nothing about a date nobody has entered yet", () => { + expect(paymentDateProblem("", period, today)).toBeNull() + }) + + it("refuses today and anything before it", () => { + expect(paymentDateProblem(today, period, today)).toBe("The date must be after today.") + expect(paymentDateProblem("2026-02-28", period, today)).toBe("The date must be after today.") + }) + + it("refuses a date before the period starts", () => { + expect(paymentDateProblem("2025-08-31", period, "2025-01-01")) + .toBe("The date must fall between 01/09/2025 and 30/11/2026.") + }) + + // Chasing the last unpaid members near the end of a period has to stay possible. + it("allows three months past the end of the period, and refuses the day after", () => { + expect(paymentDateProblem("2026-11-30", period, today)).toBeNull() + expect(paymentDateProblem("2026-12-01", period, today)).not.toBeNull() + }) + + it("has nothing to say without a period", () => { + expect(paymentDateProblem("2030-01-01", null, today)).toBeNull() + }) + + it("states the window the api will accept", () => { + expect(periodDateWindow(period)).toEqual({from: "2025-09-01", until: "2026-11-30"}) + expect(periodDateWindow(null)).toBeNull() + }) +}) + +describe("countByKind", () => { + it("counts only the rows about to be written to, by the email they are set to", () => { + const rows = [ + row({userId: 1, defaultKind: ContributionEmailKind.REMINDER}), + row({userId: 2, defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}), + row({ + userId: 3, + defaultKind: ContributionEmailKind.REMINDER, + disposition: "WARNING", + reason: BulkRowReason.ALREADY_PAID, + }), + row({userId: 4, defaultKind: ContributionEmailKind.REMINDER, disposition: "EXCLUDED"}), + ] + + expect(countByKind(rows, {}, {1: true, 2: true, 4: true})) + .toEqual({REMINDER: 1, INCASSO_NOTIFICATION: 1}) + expect(countByKind(rows, {}, {1: true, 2: true, 3: true})) + .toEqual({REMINDER: 2, INCASSO_NOTIFICATION: 1}) + }) + + it("follows a switched row to the other count", () => { + const rows = [row({userId: 2, defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION})] + + expect(countByKind(rows, {2: ContributionEmailKind.REMINDER}, {2: true})) + .toEqual({REMINDER: 1, INCASSO_NOTIFICATION: 0}) + }) +}) + +describe("summarise", () => { + const rows = [ + row({userId: 1, lastRemindedOn: "2026-01-05"}), + row({userId: 2, defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}), + row({userId: 3, disposition: "WARNING", reason: BulkRowReason.ALREADY_PAID}), + row({userId: 4, disposition: "EXCLUDED", reason: BulkRowReason.HONORARY}), + ] + + it("counts what the send would do, before it does it", () => { + expect(summarise(rows, {}, {}, seedSendTo(rows))).toEqual({ + reminders: 1, + incassoNotifications: 1, + total: 2, + notEmailed: 2, + forced: [], + switched: [], + reCharged: [], + alreadySent: [{userId: 1, name: "Ann Regular", note: "last sent 05/01/2026"}], + }) + }) + + it("counts each override the operator made", () => { + const summary = summarise( + rows, + {2: ContributionEmailKind.REMINDER}, + {1: BulkFeeType.ALUMNI_FEE}, + {1: true, 2: true, 3: true}, + ) + + expect(summary).toMatchObject({ + reminders: 3, + incassoNotifications: 0, + total: 3, + notEmailed: 1, + }) + // Named, not just counted: the confirmation shows who, and what was overruled. + expect(summary.forced).toEqual([ + {userId: 3, name: "Ann Regular", note: "Already paid this contribution"}, + ]) + expect(summary.switched.map((m) => m.userId)).toEqual([2]) + expect(summary.reCharged.map((m) => m.userId)).toEqual([1]) + }) + + // A hard exclusion is never a recipient, so it never reaches any of the override counts. + it("never counts a hard-excluded row as sent, whatever is ticked", () => { + expect(summarise(rows, {}, {}, {1: true, 2: true, 3: true, 4: true})) + .toMatchObject({total: 3, notEmailed: 1}) + }) + + it("drops a member the treasurer unticked", () => { + expect(summarise(rows, {}, {}, {1: true})).toMatchObject({total: 1, notEmailed: 3}) + }) +}) + +describe("changedKinds and changedFeeTypes", () => { + it("names only the rows moved off the email their flag chose", () => { + const rows = [ + row({userId: 1, defaultKind: ContributionEmailKind.REMINDER}), + row({userId: 2, defaultKind: ContributionEmailKind.INCASSO_NOTIFICATION}), + ] + + const changed = changedKinds(rows, { + 1: ContributionEmailKind.REMINDER, + 2: ContributionEmailKind.REMINDER, + }) + + expect(changed).toEqual({"2": ContributionEmailKind.REMINDER}) + }) + + // Stating every row's type would claim a choice where none was made. + it("names only the rows whose fee type the treasurer changed", () => { + const rows = [ + row({userId: 1, recommendedFeeType: BulkFeeType.FULL_YEAR_FEE}), + row({userId: 2, recommendedFeeType: BulkFeeType.HALF_YEAR_FEE}), + ] + + const changed = changedFeeTypes(rows, { + 1: BulkFeeType.FULL_YEAR_FEE, + 2: BulkFeeType.ALUMNI_FEE, + }) + + expect(changed).toEqual({"2": BulkFeeType.ALUMNI_FEE}) + }) + + it("names nothing when no row was touched", () => { + expect(changedFeeTypes([row({recommendedFeeType: BulkFeeType.FULL_YEAR_FEE})], { + 1: BulkFeeType.FULL_YEAR_FEE, + })).toEqual({}) + }) +}) diff --git a/services/frontend/tests/unit/utils/feeCycle.test.ts b/services/frontend/tests/unit/utils/feeCycle.test.ts deleted file mode 100644 index 4d46819cd..000000000 --- a/services/frontend/tests/unit/utils/feeCycle.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import {describe, expect, it} from "vitest" -import {BulkFeeType, BulkRowDisposition, BulkRowReason, FeeCycleGroup, MemberType} from "@/services/api" -import type {FeeCycleRowResponse} from "@/services/api" -import type {BulkRow} from "@/utils/bulkRow" -import { - changedFeeTypes, - countByGroup, - feeCycleGroupLabel, - lastAskedLabel, - toBulkRow, - toBulkRows, -} from "@/utils/feeCycle" - -function apiRow(overrides: Partial = {}): FeeCycleRowResponse { - return { - userId: 1, - name: "Ann Regular", - memberType: MemberType.REGULAR, - memberSince: "2025-09-01", - group: FeeCycleGroup.TRANSFER, - disposition: BulkRowDisposition.INCLUDED, - reason: null, - feeType: BulkFeeType.FULL_YEAR_FEE, - amount: 45, - lastAskedOn: null, - ...overrides, - } -} - -function row(overrides: Partial = {}): BulkRow { - return {...toBulkRow(apiRow()), ...overrides} -} - -describe("toBulkRow", () => { - it("carries the member, the side of the partition and what they owe", () => { - const mapped = toBulkRow(apiRow({userId: 7, group: FeeCycleGroup.DIRECT_DEBIT, amount: 25})) - - expect(mapped).toMatchObject({ - userId: 7, - name: "Ann Regular", - group: FeeCycleGroup.DIRECT_DEBIT, - disposition: BulkRowDisposition.INCLUDED, - memberSince: "2025-09-01", - amount: 25, - recommendedFeeType: BulkFeeType.FULL_YEAR_FEE, - }) - }) - - it("carries the reason a row is not written to", () => { - const mapped = toBulkRow( - apiRow({disposition: BulkRowDisposition.EXCLUDED, reason: BulkRowReason.HONORARY, feeType: null, amount: null}), - ) - - expect(mapped.disposition).toBe(BulkRowDisposition.EXCLUDED) - expect(mapped.reason).toBe(BulkRowReason.HONORARY) - expect(mapped.recommendedFeeType).toBeUndefined() - }) - - // The api answers with null; the row shape uses undefined, and the scaffold reads that. - it("turns the api's nulls into absent fields", () => { - const mapped = toBulkRow(apiRow({memberSince: null, lastAskedOn: null, reason: null})) - - expect(mapped.memberSince).toBeUndefined() - expect(mapped.lastAskedOn).toBeUndefined() - expect(mapped.reason).toBeUndefined() - }) - - it("reads the last-asked date onto the row", () => { - expect(toBulkRow(apiRow({lastAskedOn: "2026-03-04"})).lastSentOn).toBe("2026-03-04") - }) - - it("maps a whole cycle in order", () => { - const mapped = toBulkRows([apiRow({userId: 1}), apiRow({userId: 2})]) - expect(mapped.map((r) => r.userId)).toEqual([1, 2]) - }) -}) - -describe("countByGroup", () => { - it("counts each side of the partition separately", () => { - const counts = countByGroup([ - row({userId: 1, group: FeeCycleGroup.DIRECT_DEBIT}), - row({userId: 2, group: FeeCycleGroup.DIRECT_DEBIT}), - row({userId: 3, group: FeeCycleGroup.TRANSFER}), - ]) - - expect(counts).toEqual({DIRECT_DEBIT: 2, TRANSFER: 1}) - }) - - it("counts an empty cycle as zero on both sides", () => { - expect(countByGroup([])).toEqual({DIRECT_DEBIT: 0, TRANSFER: 0}) - }) -}) - -describe("feeCycleGroupLabel", () => { - it("names each side in the treasurer's words", () => { - expect(feeCycleGroupLabel(FeeCycleGroup.DIRECT_DEBIT)).toBe("Direct debit") - expect(feeCycleGroupLabel(FeeCycleGroup.TRANSFER)).toBe("Transfer") - }) - - it("has a dash for a row with no side", () => { - expect(feeCycleGroupLabel(undefined)).toBe("—") - }) -}) - -describe("lastAskedLabel", () => { - it("formats a date the way the rest of the table does", () => { - expect(lastAskedLabel("2026-03-04")).toBe("04/03/2026") - }) - - // The treasurer is scanning for who has already been asked, so never has to read as loudly - // as a date. - it("reads never rather than blank when nobody has asked yet", () => { - expect(lastAskedLabel(undefined)).toBe("Never") - expect(lastAskedLabel("not-a-date")).toBe("Never") - }) -}) - -describe("changedFeeTypes", () => { - it("sends only the types the treasurer actually changed", () => { - const rows = [ - row({userId: 1, recommendedFeeType: BulkFeeType.FULL_YEAR_FEE}), - row({userId: 2, recommendedFeeType: BulkFeeType.FULL_YEAR_FEE}), - ] - const selections = { - 1: BulkFeeType.FULL_YEAR_FEE, - 2: BulkFeeType.HALF_YEAR_FEE, - } - - expect(changedFeeTypes(rows, selections)).toEqual({"2": BulkFeeType.HALF_YEAR_FEE}) - }) - - it("sends nothing when nothing was changed", () => { - const rows = [row({userId: 1, recommendedFeeType: BulkFeeType.ALUMNI_FEE})] - expect(changedFeeTypes(rows, {1: BulkFeeType.ALUMNI_FEE})).toEqual({}) - }) - - /** - * The api refuses a type naming somebody it does not write to, so a row with no selection - * — an excluded one — must not appear in the request at all. - */ - it("leaves out a row that has no selection", () => { - const rows = [row({userId: 9, disposition: BulkRowDisposition.EXCLUDED, recommendedFeeType: undefined})] - expect(changedFeeTypes(rows, {})).toEqual({}) - }) -}) diff --git a/tests/system/src/test/kotlin/net/blueshell/acceptance/AcceptanceWorld.kt b/tests/system/src/test/kotlin/net/blueshell/acceptance/AcceptanceWorld.kt index e766610c2..4af747fd1 100644 --- a/tests/system/src/test/kotlin/net/blueshell/acceptance/AcceptanceWorld.kt +++ b/tests/system/src/test/kotlin/net/blueshell/acceptance/AcceptanceWorld.kt @@ -21,6 +21,9 @@ class AcceptanceWorld { var lastResponseBody: String? = null + /** Set by whichever step signed somebody in, so any step class can act as them. */ + var authCookies: TestHelper.LoginCookies? = null + // Registering always sends one, so "no further mail" needs a baseline. var confirmationEmailsBeforeAction: Int? = null @@ -46,4 +49,7 @@ class AcceptanceWorld { fun lastStatusCodeOrFail(): Int = lastStatusCode ?: error("No request has been made in this scenario yet.") + + fun authCookiesOrFail(): TestHelper.LoginCookies = + authCookies ?: error("Nobody is signed in — start the scenario with a Given that signs one in.") } diff --git a/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/BulkContributionSteps.kt b/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/BulkContributionSteps.kt index 6eaef514a..a86540e38 100644 --- a/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/BulkContributionSteps.kt +++ b/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/BulkContributionSteps.kt @@ -18,7 +18,6 @@ import java.time.LocalDate */ class BulkContributionSteps(private val world: AcceptanceWorld) { - private var cookies: TestHelper.LoginCookies? = null private var periodId: Long? = null private val selection = mutableListOf() private val selectedUsernames = mutableListOf() @@ -29,14 +28,14 @@ class BulkContributionSteps(private val world: AcceptanceWorld) { fun aBoardMemberSignedIn() { val board = TestHelper.registerActivateAndPromote("BOARD") world.createdUsernames += board.username - cookies = TestHelper.login(board) + world.authCookies = TestHelper.login(board) } @Given("a {string} signed in") fun aRoleSignedIn(role: String) { val user = TestHelper.registerActivateAndPromote(role) world.createdUsernames += user.username - cookies = TestHelper.login(user) + world.authCookies = TestHelper.login(user) } @Given("a contribution period they can record against") @@ -202,7 +201,7 @@ class BulkContributionSteps(private val world: AcceptanceWorld) { val ids = userIds.joinToString(",") val response = TestHelper.givenCsrfApi() .baseUri(TestEnvironment.apiUrl) - .cookie(TestEnvironment.authCookieName, requireNotNull(cookies).auth) + .cookie(TestEnvironment.authCookieName, world.authCookiesOrFail().auth) .contentType(ContentType.JSON) .body("""{"userIds":[$ids],"contributionPeriodId":$periodId}""") .`when`() diff --git a/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/PaymentEmailSteps.kt b/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/PaymentEmailSteps.kt new file mode 100644 index 000000000..d7cc9d46c --- /dev/null +++ b/tests/system/src/test/kotlin/net/blueshell/acceptance/steps/PaymentEmailSteps.kt @@ -0,0 +1,231 @@ +package net.blueshell.acceptance.steps + +import io.cucumber.java.en.Given +import io.cucumber.java.en.Then +import io.cucumber.java.en.When +import io.restassured.http.ContentType +import net.blueshell.acceptance.AcceptanceWorld +import net.blueshell.systemtests.TestEnvironment +import net.blueshell.systemtests.TestHelper +import org.assertj.core.api.Assertions.assertThat +import java.time.LocalDate + +/** + * Steps for docs/flows/payment-emails. + * + * These assert what the association guarantees: what arrived in the member's inbox, what it + * said, and what the record shows afterwards. Which status code the send answered, and which + * field a refusal names, are `BulkContributionEmailControllerIT`'s to assert — asserting them + * here would only repeat them through a slower driver. + */ +class PaymentEmailSteps(private val world: AcceptanceWorld) { + + private companion object { + const val REMINDERS = "contribution_reminders" + + /** + * How the outbox tells the two emails apart. Matched as a fragment: both subjects end + * in the academic year, which depends on the period the scenario made. + */ + const val REMINDER_SUBJECT = "Please pay your Blueshell contribution" + const val NOTIFICATION_SUBJECT = "will be collected automatically" + + const val DELIVERY_TIMEOUT_MS = 15_000L + + const val FULL_YEAR_FEE = 40.0 + const val ALUMNI_FEE = 10.0 + } + + private var periodId: Long? = null + private val selection = mutableListOf() + private var transferMember: TestHelper.RegisteredUser? = null + private var directDebitMember: TestHelper.RegisteredUser? = null + private var honoraryMember: TestHelper.RegisteredUser? = null + private var lastEmail: TestHelper.SentEmail? = null + + /** The member the scenario is about, when it has only one. */ + private fun subject(): TestHelper.RegisteredUser = + transferMember ?: directDebitMember + ?: error("This scenario has no member — start it with a Given that adds one.") + + @Given("a contribution period they can send payment emails for") + fun aContributionPeriod() { + periodId = TestHelper.createContributionPeriod( + startDate = LocalDate.now().minusMonths(6), + endDate = LocalDate.now().plusMonths(6), + fullYearFee = FULL_YEAR_FEE, + halfYearFee = 20.0, + alumniFee = ALUMNI_FEE, + ) + } + + @Given("a member who pays by transfer") + fun aTransferMember() { + transferMember = addMember(incasso = false) + } + + @Given("a member who pays by direct debit") + fun aDirectDebitMember() { + directDebitMember = addMember(incasso = true) + } + + @Given("an honorary member among the selected") + fun anHonoraryMember() { + honoraryMember = addMember(incasso = false, memberType = "HONORARY") + } + + // ── Sending ────────────────────────────────────────────────────────────── + + @When("they send the payment emails") + fun send() = post() + + @When("they move that member onto the contribution reminder and send") + fun sendSwitched() = post(kindOverrides = mapOf(idOf(subject()) to "REMINDER")) + + @When("they send the payment emails charging that member the alumni fee") + fun sendChargingAlumniFee() = post(feeTypeOverrides = mapOf(idOf(subject()) to "ALUMNI_FEE")) + + // ── What the member received ───────────────────────────────────────────── + + @Then("that member receives a contribution reminder") + fun receivesReminder() { + lastEmail = awaitEmail(subject(), REMINDER_SUBJECT) + } + + @Then("that member receives an incasso notification") + fun receivesNotification() { + lastEmail = awaitEmail(subject(), NOTIFICATION_SUBJECT) + } + + @Then("each member receives the email their payment method calls for") + fun eachReceivesTheirOwn() { + awaitEmail(requireNotNull(transferMember), REMINDER_SUBJECT) + awaitEmail(requireNotNull(directDebitMember), NOTIFICATION_SUBJECT) + } + + @Then("it states the {word} fee and what it comes to") + fun itStatesTheFee(fee: String) { + val (reason, amount) = when (fee) { + "full-year" -> "the full-year fee" to FULL_YEAR_FEE + "alumni" -> "as you are an alumni member" to ALUMNI_FEE + else -> error("Unknown fee: $fee") + } + // The amount without its symbol: the body is rendered HTML, where a € may arrive as + // an entity, and the digits are the part the assertion is about. Dutch notation, + // built rather than formatted, so a JVM default locale cannot change it. + val money = "${amount.toInt()},%02d".format((amount * 100).toInt() % 100) + assertThat(body()).contains(money).contains(reason) + } + + @Then("it says where to transfer the money") + fun itSaysWhereToPay() { + assertThat(body()).contains("Bank transfer") + } + + @Then("it asks them to transfer nothing") + fun itAsksForNoTransfer() { + assertThat(body()) + .contains("do not need to transfer anything") + .doesNotContain("Bank transfer") + } + + @Then("they are not told that anything will be taken from their account") + fun notToldAboutADebit() { + assertThat(body()).doesNotContain("collected from your bank account") + } + + @Then("the honorary member receives no payment email") + fun honoraryReceivesNothing() { + // Waiting for the other member's email first, so an absent one here is the answer + // rather than a race. Their inbox is not empty — creating a member sends them an + // activation email — so this asks only about payment emails. + awaitEmail(requireNotNull(transferMember), REMINDER_SUBJECT) + val theirs = TestHelper.findEmails(recipient = requireNotNull(honoraryMember).email) + assertThat(theirs.map { it.subject }.filter(::isPaymentEmail)).isEmpty() + } + + @Then("that member has been asked twice for this period") + fun askedTwice() { + val id = idOf(subject()) + val asks = TestHelper.findPaymentEmails(REMINDERS, requireNotNull(periodId)) + .count { it.userId == id } + assertThat(asks).isEqualTo(2) + } + + // ── Fixture ────────────────────────────────────────────────────────────── + + /** + * Waits for one of this member's emails to carry [subjectFragment]. + * + * Filtered by recipient rather than by subject: the outbox matches a subject exactly, and + * both of these end in the academic year the scenario's period works out to. + * + * A send that was refused shows up here as nothing arriving, so the failure quotes what + * the send answered — otherwise every cause reads as "no email came". + */ + private fun awaitEmail( + member: TestHelper.RegisteredUser, + subjectFragment: String, + ): TestHelper.SentEmail { + val deadline = System.currentTimeMillis() + DELIVERY_TIMEOUT_MS + var seen: List = emptyList() + while (System.currentTimeMillis() < deadline) { + seen = TestHelper.findEmails(recipient = member.email) + seen.firstOrNull { it.subject.contains(subjectFragment) }?.let { return it } + Thread.sleep(250) + } + throw AssertionError( + "No email to ${member.email} with a subject containing \"$subjectFragment\" " + + "within ${DELIVERY_TIMEOUT_MS}ms. " + + "The send answered ${world.lastStatusCode}: ${world.lastResponseBody}. " + + "That inbox holds: ${seen.map { it.subject }}", + ) + } + + private fun isPaymentEmail(subject: String): Boolean = + subject.contains(REMINDER_SUBJECT) || subject.contains(NOTIFICATION_SUBJECT) + + private fun body(): String = + requireNotNull(lastEmail) { "No email has been read yet in this scenario." }.htmlContent + + private fun idOf(user: TestHelper.RegisteredUser): Long = + requireNotNull(TestHelper.findUser(user.username)).id + + private fun addMember( + incasso: Boolean, + memberType: String = "REGULAR", + ): TestHelper.RegisteredUser { + val user = TestHelper.registerAndActivate() + world.createdUsernames += user.username + TestHelper.attachMembership(user.username, memberType = memberType, incasso = incasso) + selection += idOf(user) + return user + } + + private fun post( + kindOverrides: Map = emptyMap(), + feeTypeOverrides: Map = emptyMap(), + ) { + val body = buildString { + append("""{"contributionPeriodId":$periodId""") + append(""","userIds":[${selection.joinToString(",")}]""") + append(""","kindOverrides":${asJsonObject(kindOverrides)}""") + append(""","feeTypeOverrides":${asJsonObject(feeTypeOverrides)}""") + append(""","paymentDueDate":"${LocalDate.now().plusMonths(1)}"""") + append(""","debitDate":"${LocalDate.now().plusMonths(1).plusDays(14)}"}""") + } + val response = TestHelper.givenCsrfApi() + .baseUri(TestEnvironment.apiUrl) + .cookie(TestEnvironment.authCookieName, world.authCookiesOrFail().auth) + .contentType(ContentType.JSON) + .body(body) + .`when`() + .post("/contributions/bulk/email/send") + // Recorded, not asserted: a scenario here is about what the member received, and a + // send that answers 200 while delivering nothing is the failure worth seeing. + world.recordResponse(response.statusCode, response.asString()) + } + + private fun asJsonObject(values: Map): String = + values.entries.joinToString(",", "{", "}") { """"${it.key}":"${it.value}"""" } +} diff --git a/tests/system/src/test/kotlin/net/blueshell/systemtests/TestHelper.kt b/tests/system/src/test/kotlin/net/blueshell/systemtests/TestHelper.kt index 37eacb2ea..aa72ff418 100644 --- a/tests/system/src/test/kotlin/net/blueshell/systemtests/TestHelper.kt +++ b/tests/system/src/test/kotlin/net/blueshell/systemtests/TestHelper.kt @@ -1003,6 +1003,29 @@ object TestHelper { } } + /** Every payment email recorded for a period, as `(userId, feeType, amount)` per ask. */ + fun findPaymentEmails(table: String, periodId: Long): List = + DriverManager.getConnection(dbUrl, dbUser, dbPassword).use { conn -> + conn.prepareStatement( + "SELECT user_id, fee_type, amount FROM $table " + + "WHERE contribution_period_id = ? AND $ACTIVE_ROW_PREDICATE", + ).use { stmt -> + stmt.setLong(1, periodId) + val rs = stmt.executeQuery() + val rows = mutableListOf() + while (rs.next()) { + rows += PaymentEmailRow( + userId = rs.getLong("user_id"), + feeType = rs.getString("fee_type"), + amount = rs.getDouble("amount"), + ) + } + rows + } + } + + data class PaymentEmailRow(val userId: Long, val feeType: String?, val amount: Double) + /** * Insert a `committees` row. Returns the new committee id. */ diff --git a/tests/system/src/test/resources/features/payment-emails.feature b/tests/system/src/test/resources/features/payment-emails.feature new file mode 100644 index 000000000..53be9b307 --- /dev/null +++ b/tests/system/src/test/resources/features/payment-emails.feature @@ -0,0 +1,56 @@ +@system @contributions +Feature: Payment emails + + Twice a year the treasurer asks the members who owe a contribution for it. A member who + pays by direct debit is told what will be taken and when; everybody else is asked to + transfer. Neither email quotes an amount without the reason that amount applies, and the + association keeps a record of every asking, because the question a treasurer answers later + is who was asked and when. + + Background: + Given a board member signed in to the user manager + And a contribution period they can send payment emails for + + Scenario: A member who pays by transfer is asked to pay what they owe + Given a member who pays by transfer + When they send the payment emails + Then that member receives a contribution reminder + And it states the full-year fee and what it comes to + And it says where to transfer the money + + Scenario: A member who pays by direct debit is told what will be taken + Given a member who pays by direct debit + When they send the payment emails + Then that member receives an incasso notification + And it states the full-year fee and what it comes to + And it asks them to transfer nothing + + Scenario: One send reaches both kinds of member + Given a member who pays by transfer + And a member who pays by direct debit + When they send the payment emails + Then each member receives the email their payment method calls for + + Scenario: A member the treasurer moves receives the other email + Given a member who pays by direct debit + When they move that member onto the contribution reminder and send + Then that member receives a contribution reminder + And they are not told that anything will be taken from their account + + Scenario: A member who owes no contribution is never written to + Given a member who pays by transfer + And an honorary member among the selected + When they send the payment emails + Then the honorary member receives no payment email + + Scenario: A fee the treasurer chooses is the fee the email states + Given a member who pays by transfer + When they send the payment emails charging that member the alumni fee + Then that member receives a contribution reminder + And it states the alumni fee and what it comes to + + Scenario: Chasing a member is recorded each time + Given a member who pays by transfer + When they send the payment emails + And they send the payment emails + Then that member has been asked twice for this period