Skip to content

Activity refundable flag + organizer-configurable cancellation window

Activity refundable flag + organizer-configurable cancellation window

Section titled “Activity refundable flag + organizer-configurable cancellation window”

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.

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.refundable is required (the column is NOT NULL so the API never returns a null).
  • Client exposes only the boolean inside BookingActivityDto so the mobile UI can hide the cancel button. No new fields on CreateBookingClientDto / cancel responses — refundability is a property of the activity, not of the booking.
  • Super-admin is not affected.

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:

  1. 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 null would collide with the deferred ticket that wants null to mean “forbidden after the window” while 0 means “always allowed”.
  2. The null semantic 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.
  3. A separate boolean is cheap (single column, default true backfill, 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:

  1. Spec scope is “organizer-configurable policy”. Per-booking snapshotting is a different, larger feature (versioning of activity terms) and overshoots the half-day estimate.
  2. 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.
  3. Avoids backfill / migration of historical bookings.

Touched endpoints (all under ActivitiesAdminController, apps/api/src/app/modules/admin-api/* mounts at /api/business/*):

MethodPathoperationIdChange
POST/activitiesactivitiesAdminCreateCreateActivityDto.refundable? accepted
GET/activitiesactivitiesAdminListActivityResponseDto.refundable returned
GET/activities/:idActivitiesAdminController_findOne (autogen — see Open follow-ups)ActivityResponseDto.refundable returned
PATCH/activities/:idActivitiesAdminController_update (autogen — see Open follow-ups)UpdateActivityDto.refundable? accepted
POST/activities/:id/posterActivitiesAdminController_uploadPoster (autogen)schema-only impact via response DTO
POST/activities/:id/backdropActivitiesAdminController_uploadBackdrop (autogen)schema-only impact via response DTO
DELETE/activities/:idActivitiesAdminController_remove (autogen)schema-only impact via response DTO

DTO shapes (additions only):

// ActivityResponseDto — required
refundable: boolean;
// CreateActivityDto — optional, defaults to true at service layer
refundable?: boolean;
// UpdateActivityDto — optional (PartialType inherits automatically)
refundable?: boolean;

Touched endpoints (under ActivitiesClientController, /api/client/companies/:companyId/...):

MethodPathoperationIdChange
GET/companies/:companyId/my-bookingsbookingsClientListMineactivity.refundable included
GET/companies/:companyId/activities/:activityId/my-bookingsActivitiesClientController_findMyBookingsByActivity (autogen — see Open follow-ups)activity.refundable included

DTO shape (addition):

// BookingActivityDto — required
refundable: 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.

Not affected. Spec excludes super-admin from the affected-surfaces list.

Contract fileChange
contracts/business.openapi.yamlActivityResponseDto (+ required), CreateActivityDto, UpdateActivityDto — add refundable
contracts/client.openapi.yamlBookingActivityDto — add refundable (required)
contracts/super-admin.openapi.yamlNo 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 cancellationWindowHours on 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 === null hide-cancel gate explicitly; that ticket will decide whether to add the numeric to BookingActivityDto.

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:

Terminal window
cd tktspace-backend
npx drizzle-kit generate
# Inspect generated migration in libs/shared/data-access-db/migrations/
pnpm run db:migrate

The 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.

Feature lib: tktspace-backend/libs/features/activities/

Existing files that must change:

FileChange
libs/shared/data-access-db/src/lib/schema/activities.schema.tsAdd refundable column (line ~218, near cancellationWindowHours)
libs/features/activities/src/lib/dto/create-activity.dto.tsAdd refundable?: boolean with @ApiProperty({ default: true, description: '...' }) + @IsBoolean() + @IsOptional()
libs/features/activities/src/lib/dto/update-activity.dto.tsNo 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.tsAdd required refundable!: boolean with @ApiProperty() to ActivityResponseDto (after reminderHoursBefore, before createdAt)
libs/features/activities/src/lib/dto/booking-client.response.dto.tsAdd required refundable!: boolean with @ApiProperty() to BookingActivityDto (line 11–16)
libs/features/activities/src/lib/services/activities.service.tsIn 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.tsfindMyBookings() 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;
  • Codegen: npm run generate:api after the contract snapshot lands. Regenerates core/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”, default true. Bound via the existing reactive form.
    • cancellationWindowHours — Taiga UI number input (tui-input-number), label “Cancellation window (hours)”, min=1, step=1, nullable, gated by refundable === true (hide / disable when toggled off).
  • No new routes, no new modal — purely an additive form change.
  • Service layer (activities-api.service.ts or equivalent) needs no signature change because regenerated DTOs absorb the field.

Not affected — the public web app does not expose cancel-booking UI. Out of scope.

Not affected.

  • App: tktspace-mobile-app/apps/gym_app only. tickets_app is excluded per spec (“Non-goals — tickets-mobile cancel-booking UI”).
  • Shared packages: packages/api only (regenerated DTOs).
Terminal window
cd tktspace-mobile-app
melos run sync:spec # pulls the refreshed client.openapi.yaml
melos run generate:api # rebuilds packages/api with refundable field

apps/gym_app/lib/pages/my_bookings/my_bookings_page.dart line 333:

// Before
if (!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.

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.

RiskMitigation
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.

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):

  1. Phase B — Backend MR: schema change → drizzle-kit generate → DTOs / services / @ApiPropertypnpm run sync:contracts → commit contract snapshots in _workflow/contracts/.
  2. Phase C — Business MR: npm run generate:api → activity-form inputs → unit smoke.
  3. Phase D — Mobile MR: melos run sync:spec && melos run generate:apimy_bookings_page.dart gate → 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 memory feedback_drizzle_migrations.md). The file _workflow/drafts/migration-activity-refundable-and-cancellation-window.sql is a reference preview only and MUST NOT be committed or applied — the canonical migration lives under tktspace-backend/libs/shared/data-access-db/migrations/ and is produced by drizzle-kit generate.
  • Migration applied locally via pnpm run db:migrate before booting the API for sync:contracts.
  • CreateActivityDto.refundable? carries @ApiProperty({ required: false, default: true, description: '...' }) and @IsBoolean() @IsOptional().
  • UpdateActivityDto.refundable? is inherited via PartialType — verify the snapshot picks it up. If it doesn’t (PartialType + Swagger plugin gotcha), declare it explicitly with @ApiProperty({ required: false }).
  • ActivityResponseDto.refundable declared with @ApiProperty() (no required: false) so it lands in required: [].
  • BookingActivityDto.refundable declared with @ApiProperty(). The controller (activities-client.controller.ts) already promotes BookingActivityDto via @ApiExtraModels — no change needed there.
  • Service layer: - activities.service.ts#createrefundable: dto.refundable ?? true - activities.service.ts#update — pass through dto.refundable (undefined leaves it unchanged) - activities-client.service.ts#findMyBookings — add refundable: activities.refundable to the activity sub-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, assert refundable === false; create with no field, assert default true. Plus a client-surface assertion that my-bookings returns the field.
  • pnpm run sync:contracts executed against the locally-booted backend.
  • _workflow/contracts/business.openapi.yaml and _workflow/contracts/client.openapi.yaml diffs committed in the same MR as the backend code change.
  • npm run sync:contracts:check passes.

These are explicitly deferred per the spec’s “Known deltas” or “Non-goals” sections. Listing them here keeps the ADR honest about scope.

  1. 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.
  2. cancellationWindowHours === null hide-cancel gate — Spec “Known deltas”. The deferred ticket may or may not need to expose cancellationWindowHours on BookingActivityDto. Decided then.
  3. refundable === false enforcement at bookingsClientCancel — Spec “Non-goals” and AC-9. The cancel endpoint stays permissive in this ticket. A follow-up ticket adds the 422 guard.
  4. Missing operationIds on admin controller — Verified via grep: ActivitiesAdminController.findOne, .update, .uploadPoster, .uploadBackdrop, .remove have no operationId on @ApiOperation and currently emit Nest-default names (ActivitiesAdminController_findOne, etc.). Same gap on the client controller’s findMyBookingsByActivity. This ADR does NOT include the rename. Per _workflow/CLAUDE.md authoring rules, every @ApiOperation should declare its operationId — 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.
  5. tickets_app cancel UI — Placeholder today; gets its own ticket under the P1 booking-detail track.