Bulk contract cancellation
Bulk cancellation is a bounded local Admin workflow. It implements the observed two-stage pattern of preview followed by explicit execution, but it is not a public Frisbii bulk API and does not claim parity with an undocumented private endpoint.
Admin workflow
The HTML routes are:
-
GET /admin/bulk_contract_actions— show the selection form; -
POST /admin/bulk_contract_actions/preview— validate the selection and create a signed review; -
POST /admin/bulk_contract_actions/execute— accept onlyreview_tokenand the scalar UI intent guardconfirm_execution="1". A missing value,"0", an array, or any unknown execute field fails in the controller before the backend is called.
Only a provisioned full Admin for the current merchant may use these routes. ImportOnly=true merchants and ImportState=ReadOnly contracts are rejected before a review or contract mutation is written. Responses use Cache-Control: no-store.
The form action is exactly CancelContracts. Upgrade/downgrade and the payment-escalation filter remain disabled because their semantics are outside this phase.
Selection inputs
A CSV is optional. When present, the Admin chooses exactly one header and identity type: ContractId, ExternalContractId, CustomerId, ExternalCustomerId, or DebitorAccount.
The local CSV contract is UTF-8 with an optional BOM, comma-delimited RFC 4180, and LF or CRLF line endings. It contains one exact header and 1 through 100 unique non-empty identifiers, with no extra columns or blank rows. Each identifier is limited to 255 bytes; the whole upload is limited to 256 KiB and rejected while reading byte 262,145 or row 101. ContractId and CustomerId are raw lowercase 24-hex IDs. External and debtor identifiers use exact case-sensitive matches. Leading =, +, -, and @ remain literal text and are never evaluated or exported.
ContractId directly resolves at most 100 same-merchant contracts. All no-CSV and other identity paths preflight at most 5,001 merchant contracts and fail at 5,001. ExternalCustomerId and DebitorAccount additionally preflight the customer archive at 5,001. Customer/debtor fan-out is fully resolved and fails rather than truncating when it exceeds 100. The accepted cohort is ordered by (created_at ASC, id ASC) and frozen in the review; a later matching contract is not added.
The required lifecycle filter is All, InTrial, or Active. Local All means InTrial plus Active. The optional DunningLevel is an integer from 0 through 100 and matches only a stored integer. A CSV row excluded by either filter is reported as FilterMismatch; it is not silently dropped.
Effective date modes
There is no implicit form default. The supported values are:
| Mode | Local behavior |
|---|---|
Now |
One shared preview instant is used for every selected contract. |
NextBillingDate |
Uses each contract’s stored value. |
LastBillingDate |
Uses each contract’s stored value; a past value follows the existing immediate-end behavior. |
BilledUntil |
Uses each contract’s stored value. |
RegularContractEnd |
Reuses Billing::ContractTerms#cancellation_preview, requires computed CanCancel=true, and freezes its end date and terms fingerprint. |
Every date must be an RFC 3339 instant with Z or an explicit offset and at most 128 bytes. Values are normalized to millisecond UTC. Missing or invalid stored dates fail. Every mode rejects an existing EndDate, a lifecycle outside InTrial/Active, and any pending lifecycle schedule. CanCancel is computed only for RegularContractEnd; the other modes do not invent a stored field.
Rejections and review security
Selection, eligibility, date, and schedule failures return a bounded transient reason list in Billing::Error#details["Errors"]. The Admin controller renders at most 100 rows. A rejected preview creates no token and no bulk_contract_reviews record. It never stores the uploaded CSV or its external, customer, or debtor identifiers.
An accepted preview has RejectedCount=0 and persists an immutable Snapshot of at most 512 KiB in bulk_contract_reviews. The browser receives an actor- and merchant-bound signed token with a dedicated purpose, a 10-minute expiry, and a 16 KiB limit. The Snapshot freezes the ordered contract IDs, lock versions, selected dates, applicable terms, and cancellation-relevant state fingerprints. It does not contain customer names, addresses, email addresses, financial values, raw contract JSON, or CSV bytes.
Execution ignores submitted IDs, dates, filters, and counts. The confirmation checkbox is an unsigned controller-level proof of current UI intent; it is neither replay authority nor an argument to Billing::BulkContractExecute. The still-valid signed review token is the backend’s sole domain authority. The backend locks the merchant and review, then revalidates every reviewed contract, customer binding, filter field, effective-date source, terms result, lifecycle, import state, end state, and pending schedules before ending the first contract.
Commit and replay
The batch is all-or-nothing. Between 1 and 100 cancellations commit in one database transaction. Any stale row, unexpected typed-history result, or domain/persistence error rolls back every contract, lifecycle schedule, typed change, idempotency record, result, and outbound event.
Complete-history contracts must persist the reviewed EndContract change, inactive phase, and coherent pending or applied lifecycle schedule. Legacy contracts persist the exact end state without fabricating typed history. The workflow initiates no payment, refund, PSP, bank, SMTP, webhook, or external-provider operation.
Each accepted review contains a random 64-hex batch identity. Per-contract Dispatcher keys are the 76-character value bulk-cancel:<sha256> derived from the action domain, batch identity, and raw contract ID. One immutable bulk_contract_results record, capped at 128 KiB, stores the sanitized result. A concurrent or repeated submission with the same still-valid token returns that exact result. A changed review digest, batch key, actor, merchant, or token fails with 409.
Exact replay is available only while the signed token remains valid for its original 10-minute window. Retaining a native review/result for at least one day provides durability and audit evidence; it does not extend token validity and does not authorize lookup or replay by review ID, digest, or batch key alone.
Service interface
Controllers use the backend directly:
preview = Billing::BulkContractReview.new( merchant: current_merchant, admin_user: current_admin_user, request_id: request.request_id ).call( identity_type: "ContractId", # nil when no CSV is supplied csv: uploaded_file&.tempfile, # String or readable IO lifecycle: "All", dunning_level: nil, effective_mode: "RegularContractEnd" ) result = Billing::BulkContractExecute.new( merchant: current_merchant, admin_user: current_admin_user, request_id: request.request_id, base_url: request.base_url ).call(token: preview.token)
preview.snapshot is the renderable accepted review and result is the strict sanitized committed or replayed Hash. A caller renders Billing::Error#details for rejected rows and must not attempt to construct or modify a Snapshot or result.
Audit, retention, and evidence boundary
Batch audits contain only review ID/digest, effective mode, selected/error counts, outcome, and reason code. They exclude contract/customer/external/debtor identifiers, token, nonce, batch key, state fingerprints, and CSV content.
Preview housekeeping examines at most 100 eligible records per request. Each merchant/Admin may retain at most 20 unexpired unused reviews and 500 total review/result pairs. Expired unused reviews may be removed; used reviews and results are retained for at least one day. Database triggers bind merchant, Admin, nonce, timestamps, digest, batch identity, JSON byte counts, and the result-before- used transition, and prevent rewriting committed results.
The current Abbrechen link only leaves the review page. It does not revoke or delete the native review. An abandoned accepted review remains pending until its 10-minute expiry and later bounded housekeeping, and it counts toward the 20-unexpired-review cap during that interval.
The implementation and its PostgreSQL tests are independently approved in bulk-contract-cancellation-backend.md. The source observation is recorded in gui-observations-2026-09-06.md, and the complete local decision record is bulk-contract-actions-plan.md. These sources support the local workflow and its bounds. They do not establish Frisbii batch atomicity, CSV grammar, offset/custom-date behavior, upgrade semantics, payment-escalation mapping, notifications, or provider effects.