Activity refundable flag + organizer-configurable cancellation window
Activity refundable flag + organizer-configurable cancellation window
Section titled “Activity refundable flag + organizer-configurable cancellation window”Context
Section titled “Context”P1 tickets-mobile needs a server-side signal that says “this activity does not
allow customer-initiated cancellation”. Today, the only refund-related lever on
an activity is activities.cancellation_window_hours (nullable, default 2),
which is consumed by BookingsClientService.cancelBooking but is not exposed
to the client UI: the gym_app my_bookings_page.dart always renders the
“Cancel booking” button for any non-cancelled / non-refunded booking. The spec
calls for a dedicated boolean refundable on activities, surfaced on both
business (organizer config) and client (UI gate) contracts. The
cancellationWindowHours numeric input already exists in the schema but is
not yet rendered in the business activity form — this ticket adds the input
alongside the new toggle.
The work is the smallest possible P1 pre-req for the manual refund flow. It establishes a reusable activity-level config-flag pattern (DB column → admin DTO trio → client DTO subset → mobile UI gate) without touching the cancel endpoint, LiqPay, or the wallet refund pipe.
Decision
Section titled “Decision”Add a single column activities.refundable BOOLEAN NOT NULL DEFAULT true, fan
out the field through the business activity DTO trio (Create / Update /
Response) and the client BookingActivityDto, regenerate both contract
snapshots, and gate the gym_app cancel button on
booking.activity.refundable != false. The cancel endpoint is intentionally
unchanged in this ADR — its enforcement belongs to a follow-up.
Field visibility per surface is asymmetric on purpose:
- Business exposes the full trio so an organizer can flip the toggle when
creating or editing an activity.
ActivityResponseDto.refundableis required (the column isNOT NULLso the API never returns a null). - Client exposes only the boolean inside
BookingActivityDtoso the mobile UI can hide the cancel button. No new fields onCreateBookingClientDto/ cancel responses — refundability is a property of the activity, not of the booking. - Super-admin is not affected.
Considered alternatives
Section titled “Considered alternatives”Alt 1 — Reuse cancellationWindowHours = null as the “non-refundable” signal
Section titled “Alt 1 — Reuse cancellationWindowHours = null as the “non-refundable” signal”Today the column’s nullable semantics already encode “cancellation forbidden”
(cancelBooking throws errors.booking.cancel_not_allowed when it is null
and money was debited). We could ship the gym_app gate against null instead
of adding a column.
Rejected because:
- The spec’s locked product decision keeps the numeric window as a separate,
user-tunable knob (“free cancellation up to N hours before the session”),
not as a tri-state encoding of three different policies. Overloading
nullwould collide with the deferred ticket that wantsnullto mean “forbidden after the window” while0means “always allowed”. - The
nullsemantic is already inconsistent across the codebase — the spec’s “Known deltas” call out that gym_app doesn’t honor it today and the fix is its own ticket. Building new UI on top of that ambiguity multiplies the cleanup later. - A separate boolean is cheap (single column, default
truebackfill, zero downtime ALTER) and reads as documentation at every call site.
Alt 2 — Store refundability per-booking instead of per-activity
Section titled “Alt 2 — Store refundability per-booking instead of per-activity”A booking-level refundable flag (bookings.refundable) snapshotted at
booking creation would let the policy travel with the booking, surviving
later activity edits.
Rejected because:
- Spec scope is “organizer-configurable policy”. Per-booking snapshotting is a different, larger feature (versioning of activity terms) and overshoots the half-day estimate.
- None of the consumer use-cases (UI gate, deferred endpoint guard) need point-in-time consistency. If an organizer toggles refundable off, the business behavior we want is “existing bookings can no longer be cancelled from the UI starting now” — which is exactly what a per-activity field delivers.
- Avoids backfill / migration of historical bookings.
API design (per surface)
Section titled “API design (per surface)”Business surface (/api/business/*)
Section titled “Business surface (/api/business/*)”Touched endpoints (all under ActivitiesAdminController,
apps/api/src/app/modules/admin-api/* mounts at /api/business/*):
| Method | Path | operationId | Change |
|---|---|---|---|
POST | /activities | activitiesAdminCreate | CreateActivityDto.refundable? accepted |
GET | /activities | activitiesAdminList | ActivityResponseDto.refundable returned |
GET | /activities/:id | ActivitiesAdminController_findOne (autogen — see Open follow-ups) | ActivityResponseDto.refundable returned |
PATCH | /activities/:id | ActivitiesAdminController_update (autogen — see Open follow-ups) | UpdateActivityDto.refundable? accepted |
POST | /activities/:id/poster | ActivitiesAdminController_uploadPoster (autogen) | schema-only impact via response DTO |
POST | /activities/:id/backdrop | ActivitiesAdminController_uploadBackdrop (autogen) | schema-only impact via response DTO |
DELETE | /activities/:id | ActivitiesAdminController_remove (autogen) | schema-only impact via response DTO |
DTO shapes (additions only):
// ActivityResponseDto — requiredrefundable: boolean;
// CreateActivityDto — optional, defaults to true at service layerrefundable?: boolean;
// UpdateActivityDto — optional (PartialType inherits automatically)refundable?: boolean;Client surface (/api/client/*)
Section titled “Client surface (/api/client/*)”Touched endpoints (under ActivitiesClientController,
/api/client/companies/:companyId/...):
| Method | Path | operationId | Change |
|---|---|---|---|
GET | /companies/:companyId/my-bookings | bookingsClientListMine | activity.refundable included |
GET | /companies/:companyId/activities/:activityId/my-bookings | ActivitiesClientController_findMyBookingsByActivity (autogen — see Open follow-ups) | activity.refundable included |
DTO shape (addition):
// BookingActivityDto — requiredrefundable: boolean;Out of scope (verified, not patched in this ADR):
CreateBookingResponseDto,CancelBookingResponseDto,BookingRecordDto— none carry the activity preview; no change.ActivityPreviewClientResponseDto/ActivityDetailClientResponseDto(browse/detail pages) — refundable is not consumed there; can be added later if the discovery surface needs it.
Super-admin surface (/api/superadmin/*)
Section titled “Super-admin surface (/api/superadmin/*)”Not affected. Spec excludes super-admin from the affected-surfaces list.
Surface impact
Section titled “Surface impact”| Contract file | Change |
|---|---|
contracts/business.openapi.yaml | ActivityResponseDto (+ required), CreateActivityDto, UpdateActivityDto — add refundable |
contracts/client.openapi.yaml | BookingActivityDto — add refundable (required) |
contracts/super-admin.openapi.yaml | No change |
Field-level differences across surfaces — why the client gets only the
boolean and not the matching numeric cancellationWindowHours:
- The organizer (business) is the only party that configures
cancellationWindowHours. The mobile UI never displays the value directly; it only consumes the boolean to decide whether to show the cancel button. - Exposing
cancellationWindowHourson the client surface would tempt the app to render a “you can cancel until X” countdown — that is a deferred ticket (see Open follow-ups) with its own UX requirements. Leaking the field now without a UI plan invites client-side hard-coding. - The “Known deltas” section of the spec calls out the deferred
cancellationWindowHours === nullhide-cancel gate explicitly; that ticket will decide whether to add the numeric toBookingActivityDto.
Data model
Section titled “Data model”Table: activities.activities
File: tktspace-backend/libs/shared/data-access-db/src/lib/schema/activities.schema.ts (lines 191–248)
Add one column to the existing activities table definition:
// Drizzle preview — to be added in activities.schema.ts around the existing// cancellation_window_hours column (line 218):refundable: boolean('refundable').notNull().default(true),No index, no constraint, no relation change. The existing
activities_sphere_id_idx and activities_title_trgm_idx are untouched.
Migration generation:
cd tktspace-backendnpx drizzle-kit generate# Inspect generated migration in libs/shared/data-access-db/migrations/pnpm run db:migrateThe generated migration is expected to be a single
ALTER TABLE activities.activities ADD COLUMN refundable boolean NOT NULL DEFAULT true;
statement. Existing rows are backfilled to true by the DEFAULT — no
data-migration step required. See drafts/migration-activity-refundable-and-cancellation-window.sql
for the expected SQL preview.
Backend module placement
Section titled “Backend module placement”Feature lib: tktspace-backend/libs/features/activities/
Existing files that must change:
| File | Change |
|---|---|
libs/shared/data-access-db/src/lib/schema/activities.schema.ts | Add refundable column (line ~218, near cancellationWindowHours) |
libs/features/activities/src/lib/dto/create-activity.dto.ts | Add refundable?: boolean with @ApiProperty({ default: true, description: '...' }) + @IsBoolean() + @IsOptional() |
libs/features/activities/src/lib/dto/update-activity.dto.ts | No code change — PartialType(CreateActivityDto) picks the field up automatically; confirm the contract snapshot reflects the optional refundable |
libs/features/activities/src/lib/dto/activity.response.dto.ts | Add required refundable!: boolean with @ApiProperty() to ActivityResponseDto (after reminderHoursBefore, before createdAt) |
libs/features/activities/src/lib/dto/booking-client.response.dto.ts | Add required refundable!: boolean with @ApiProperty() to BookingActivityDto (line 11–16) |
libs/features/activities/src/lib/services/activities.service.ts | In create() (line ~70) — pass refundable: dto.refundable ?? true. In update() (line ~306) — pass through unchanged via spread / explicit pick; ensure undefined leaves the column untouched |
libs/features/activities/src/lib/services/activities-client.service.ts | findMyBookings() select at line 596–601: add refundable: activities.refundable. findMyBookingsByActivity() select at line 675–680: add refundable: activities.refundable |
No new shared utilities. No new per-surface module. No new module wiring needed
in apps/api/src/app/modules/admin-api/ or client-api/ — both controllers
already exist and are mounted.
Per CLAUDE.md authoring rules, each @ApiProperty MUST carry a
description and (where relevant) default: true. Example for
CreateActivityDto:
@ApiProperty({ required: false, default: true, description: 'Whether bookings for this activity can be cancelled and refunded. Defaults to true.',})@IsBoolean()@IsOptional()refundable?: boolean;Frontend implications
Section titled “Frontend implications”tktspace-business
Section titled “tktspace-business”- Codegen:
npm run generate:apiafter the contract snapshot lands. Regeneratescore/api/models/{activity-response-dto,create-activity-dto,update-activity-dto}.ts. - Activity form:
client/src/app/features/dashboard/activities/pages/activity-form/needs two new inputs:refundable— Taiga UI toggle (tui-toggle), label “Refundable bookings”, defaulttrue. Bound via the existing reactive form.cancellationWindowHours— Taiga UI number input (tui-input-number), label “Cancellation window (hours)”,min=1,step=1, nullable, gated byrefundable === true(hide / disable when toggled off).
- No new routes, no new modal — purely an additive form change.
- Service layer (
activities-api.service.tsor equivalent) needs no signature change because regenerated DTOs absorb the field.
tktspace-web
Section titled “tktspace-web”Not affected — the public web app does not expose cancel-booking UI. Out of scope.
tktspace-landing
Section titled “tktspace-landing”Not affected.
Mobile implications
Section titled “Mobile implications”Affected apps and packages
Section titled “Affected apps and packages”- App:
tktspace-mobile-app/apps/gym_apponly.tickets_appis excluded per spec (“Non-goals — tickets-mobile cancel-booking UI”). - Shared packages:
packages/apionly (regenerated DTOs).
Codegen
Section titled “Codegen”cd tktspace-mobile-appmelos run sync:spec # pulls the refreshed client.openapi.yamlmelos run generate:api # rebuilds packages/api with refundable fieldScreen changes
Section titled “Screen changes”apps/gym_app/lib/pages/my_bookings/my_bookings_page.dart line 333:
// Beforeif (!isCancelled) ...[ // cancel button]
// After (per spec AC-8)if (!isCancelled && booking.activity.refundable != false) ...[ // cancel button — hidden when the activity is explicitly non-refundable; // null is treated as refundable to keep behaviour stable for old payloads.]No new pages, no new state, no offline implications, no deep-linking changes.
The cancel handler (_cancelBooking) is unchanged.
Other mobile apps
Section titled “Other mobile apps”apps/tickets_app is unaffected because its booking-detail UI is a
placeholder — see spec’s “Non-goals”. dining_app (planned, not yet on disk)
inherits the regenerated DTO automatically when it lands.
| Risk | Mitigation |
|---|---|
Existing rows have no value for refundable after the ALTER — Postgres backfills with the DEFAULT but is the DEFAULT applied during ALTER ADD COLUMN NOT NULL? | Yes — Postgres 11+ applies the DEFAULT to existing rows in-place at ALTER time without a table rewrite. Migration is safe on production-sized data. |
BookingsClientService.cancelBooking does not enforce refundable === false, so a customer reaching the endpoint by other means still cancels. | Documented as out of scope (AC-9). Tracked as a separate booking-cancel-enforcement ticket. Worst-case behaviour today is unchanged. |
Contract drift if backend MR merges without sync:contracts rerun. | CI guard npm run sync:contracts:check (per _workflow/CLAUDE.md). The build pipeline’s Phase D should fail if the snapshot is stale. |
Mobile codegen regression — swagger_dart_code_generator may emit refundable as nullable on the Dart side. | Already accounted for in AC-8: gym_app treats refundable != false, i.e. nullable and true both keep the button visible. |
cancellationWindowHours numeric input visible only when refundable=true could hide previously-set values from the organizer. | The form preserves the underlying form-control value; toggling refundable back on restores the prior input. The backend does NOT clear the column when refundable flips. |
Performance — findMyBookings select gains one column. | Negligible (boolean adds 1 byte per row). The query already joins activities; no new join. |
Security — exposing a new field on /api/client/*. | refundable is a public policy flag with no PII or auth implications. Safe by inspection. |
Rollout plan
Section titled “Rollout plan”No feature flag. The migration is forward-only and the default value (true)
preserves current behaviour for every existing activity. Phased rollout is
unnecessary at this scope.
Order of operations (matches /build pipeline phases):
- Phase B — Backend MR: schema change →
drizzle-kit generate→ DTOs / services /@ApiProperty→pnpm run sync:contracts→ commit contract snapshots in_workflow/contracts/. - Phase C — Business MR:
npm run generate:api→ activity-form inputs → unit smoke. - Phase D — Mobile MR:
melos run sync:spec && melos run generate:api→my_bookings_page.dartgate → flutter analyze + run on simulator.
Backfill: none required. Roll-forward only.
Cross-surface coordination notes (mandatory backend checklist)
Section titled “Cross-surface coordination notes (mandatory backend checklist)”These must be observed in the backend MR (Phase B). Listed verbatim so the backend agent has no room to skip:
- Schema column added to
activities.schema.ts:refundable: boolean('refundable').notNull().default(true). - Migration auto-generated via
npx drizzle-kit generate(NEVER hand-written — see user memoryfeedback_drizzle_migrations.md). The file_workflow/drafts/migration-activity-refundable-and-cancellation-window.sqlis a reference preview only and MUST NOT be committed or applied — the canonical migration lives undertktspace-backend/libs/shared/data-access-db/migrations/and is produced bydrizzle-kit generate. - Migration applied locally via
pnpm run db:migratebefore booting the API forsync:contracts. -
CreateActivityDto.refundable?carries@ApiProperty({ required: false, default: true, description: '...' })and@IsBoolean() @IsOptional(). -
UpdateActivityDto.refundable?is inherited viaPartialType— verify the snapshot picks it up. If it doesn’t (PartialType + Swagger plugin gotcha), declare it explicitly with@ApiProperty({ required: false }). -
ActivityResponseDto.refundabledeclared with@ApiProperty()(norequired: false) so it lands inrequired: []. -
BookingActivityDto.refundabledeclared with@ApiProperty(). The controller (activities-client.controller.ts) already promotesBookingActivityDtovia@ApiExtraModels— no change needed there. - Service layer:
-
activities.service.ts#create—refundable: dto.refundable ?? true-activities.service.ts#update— pass throughdto.refundable(undefined leaves it unchanged) -activities-client.service.ts#findMyBookings— addrefundable: activities.refundableto theactivitysub-select (line ~596) -activities-client.service.ts#findMyBookingsByActivity— same addition (line ~675) - Backend e2e test added: create activity with
refundable: false, GET it back via business API, assertrefundable === false; create with no field, assert defaulttrue. Plus a client-surface assertion thatmy-bookingsreturns the field. -
pnpm run sync:contractsexecuted against the locally-booted backend. -
_workflow/contracts/business.openapi.yamland_workflow/contracts/client.openapi.yamldiffs committed in the same MR as the backend code change. -
npm run sync:contracts:checkpasses.
Open follow-ups (NOT decided here)
Section titled “Open follow-ups (NOT decided here)”These are explicitly deferred per the spec’s “Known deltas” or “Non-goals” sections. Listing them here keeps the ADR honest about scope.
- SHOW default
cancellationWindowHours = 24— Spec “Known deltas”. The per-type service-layer override belongs to the ticket that adds SHOW activity creation through the business form. This ADR leaves the DB default at 2 and applies no service override. cancellationWindowHours === nullhide-cancel gate — Spec “Known deltas”. The deferred ticket may or may not need to exposecancellationWindowHoursonBookingActivityDto. Decided then.refundable === falseenforcement atbookingsClientCancel— Spec “Non-goals” and AC-9. The cancel endpoint stays permissive in this ticket. A follow-up ticket adds the 422 guard.- Missing
operationIds on admin controller — Verified via grep:ActivitiesAdminController.findOne,.update,.uploadPoster,.uploadBackdrop,.removehave nooperationIdon@ApiOperationand currently emit Nest-default names (ActivitiesAdminController_findOne, etc.). Same gap on the client controller’sfindMyBookingsByActivity. This ADR does NOT include the rename. Per_workflow/CLAUDE.mdauthoring rules, every@ApiOperationshould declare itsoperationId— surfaceable as a backend-hygiene follow-up (proposed names:activitiesAdminFindOne,activitiesAdminUpdate,activitiesAdminUploadPoster,activitiesAdminUploadBackdrop,activitiesAdminRemove,bookingsClientListMineByActivity). Changing the names is breaking for codegen and is best handled in its own dedicated “operationId backfill” sweep. tickets_appcancel UI — Placeholder today; gets its own ticket under the P1 booking-detail track.