Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
dc137e9
Keep existing payments refundable when new sales are off
stefan-burke Jul 31, 2026
bc4a6dc
Address review: shared provider type, atomic provider transition
stefan-burke Aug 1, 2026
dd7c324
Update equivalent-mutant line numbers after review refactor
stefan-burke Aug 1, 2026
3fbb3d6
Tighten wording and use using-declarations for test stubs
stefan-burke Aug 1, 2026
f0d6acc
Keep operator UI correct when sales are off; fix broken flake
stefan-burke Aug 1, 2026
5345a26
Fix pre-existing none recovery and concurrency race
stefan-burke Aug 2, 2026
16e318d
Address review: unified provider resolver, RETURNING, revert templates
stefan-burke Aug 2, 2026
248967e
Add raw-writes mirror test; lint fixes
stefan-burke Aug 2, 2026
17a9483
Replace rawWritesApi with valibot tupleWithRest; restore distinct las…
stefan-burke Aug 2, 2026
9c67112
Kill template mutation survivors; fix stale equivalent entries
stefan-burke Aug 2, 2026
b6dbcd7
Kill 65/94 mutation survivors with compact table-driven assertions
stefan-burke Aug 2, 2026
5f757d5
Kill remaining 27 mutation survivors; compact tests under line cap
stefan-burke Aug 2, 2026
d98a098
Remove as-unknown-as cast; use typed signedMeta input for thank_you_url
stefan-burke Aug 2, 2026
80e7542
Save WIP
stefan-burke Aug 2, 2026
db336b4
Delete dead loaded.add writes; kill client.ts mutants
stefan-burke Aug 3, 2026
aa3aaaa
Move invalidation test to client suite; use CREATE TABLE for bare DB
stefan-burke Aug 3, 2026
3ea050a
Use unique test-only table for invalidation assertion
stefan-burke Aug 3, 2026
f18d7b0
Use INSERT not CREATE so invalidation parser fires on mutant
stefan-burke Aug 3, 2026
2b8bc24
Behavior-focused webhooks tests without mutation jargon
stefan-burke Aug 3, 2026
81a9425
Add behavior tests for distinguishable webhook mutants
stefan-burke Aug 3, 2026
7ec84c7
Extract failureDetail helper; separate specific log assertions
stefan-burke Aug 3, 2026
b906cbf
Fix equivalent-mutant line numbers after failureDetail extraction
stefan-burke Aug 3, 2026
6a835c8
Fix missing setupStripe in helper; add 503/data-payment-result/empty-…
stefan-burke Aug 3, 2026
d6c57df
Split webhook edge-case tests by route
stefan-burke Aug 3, 2026
656c954
Cover every payment callback mutation
stefan-burke Aug 3, 2026
c988434
Complete existing payment provider recovery
stefan-burke Aug 3, 2026
45e7840
Add direct settings route contract
stefan-burke Aug 3, 2026
704933b
Move provider recovery tests to mirror
stefan-burke Aug 3, 2026
cd669d0
Kill remaining payment safety mutants
stefan-burke Aug 3, 2026
92fc06c
Verify settings POST route dispatch
stefan-burke Aug 3, 2026
7daf37c
Harden payment provider recovery
stefan-burke Aug 3, 2026
f1eec7a
Add direct domain settings tests
stefan-burke Aug 3, 2026
a8f275d
Mirror payment provider form tests
stefan-burke Aug 3, 2026
55d10e2
Add payment provider recovery stories
stefan-burke Aug 3, 2026
6df7bd4
Exclude concurrent mutation test work
stefan-burke Aug 3, 2026
ff728d1
Finish payment provider recovery review fixes
stefan-burke Aug 3, 2026
05ca3c6
Cover payment provider form mutations
stefan-burke Aug 3, 2026
862bdd0
Finish payment recovery review fixes
stefan-burke Aug 3, 2026
bdefe2d
Cover atomic provider recovery state
stefan-burke Aug 3, 2026
0c4b76e
Preserve concurrent provider settings
stefan-burke Aug 3, 2026
ff70865
Merge remote-tracking branch 'origin/main' into payment-aggregate/01-…
stefan-burke Aug 4, 2026
17e3ea5
Use split Stripe configuration in tests
stefan-burke Aug 4, 2026
909b396
Serialize provider settings changes
stefan-burke Aug 4, 2026
2871d65
Merge remote-tracking branch 'origin/main' into payment-aggregate/01-…
stefan-burke Aug 4, 2026
a2676cc
Fix settings revision CI failures
stefan-burke Aug 4, 2026
6f4fafc
Resolve final provider settings checks
stefan-burke Aug 4, 2026
550b6ce
Cover provider settings guards
stefan-burke Aug 4, 2026
8fba391
Merge remote-tracking branch 'origin/main' into payment-aggregate/01-…
stefan-burke Aug 4, 2026
adab54e
Check what the customer was actually charged, and never lose a paymen…
stefan-burke Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,30 @@ look.
fail-closed behaviour. See the "Respect the subrequest budget" guidance in
AGENTS.md.

## Payment aggregate — safety behaviour (PR 1)

*Origin: the first incremental step toward the `base/payment-aggregate` rewrite.
That branch carries the full aggregate (owner-review cases, queued owner email,
aggregate activation); this PR lands only the safety property the rest of the
series depends on, on `main` as it is today.*

New sales and existing payments are now resolved by different questions:
`getActivePaymentProvider()` / `isPaymentsEnabled()` gate new checkouts;
`getPaymentProviderForExistingPayments()` resolves the provider for refunds,
provider reconciliation, replayed callbacks, and completion of already-started
payment work. When new sales are switched off (provider saved as "none"), the
existing-payment paths fall back to the last provider the operator activated —
whose credentials stay stored — so money already captured is never stranded.
Comment thread
stefan-burke marked this conversation as resolved.
Outdated

The seven accepted safety rules the aggregate must satisfy — including the ones
not yet implementable on `main` (owner review, queued owner email, aggregate
activation) — are recorded as acceptance constraints in
[`docs/payment-aggregate-acceptance.md`](docs/payment-aggregate-acceptance.md),
not implemented ahead of their time. Later-stage findings from the aggregate
base branch (its own payment-rewrite coverage gaps and the eight Codex findings
recorded in its TODO) belong to when the aggregate itself lands, and are not
copied here.

## Request performance: consolidate AsyncLocalStorage scopes

`src/features/app/request.ts` enters eleven nested request scopes for locale, client
Expand Down
103 changes: 103 additions & 0 deletions docs/payment-aggregate-acceptance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Payment aggregate — accepted safety rules

These seven rules are the safety contract the payment aggregate must satisfy.
They describe behaviour that does not all exist on `main` yet: the owner-review
flow, the queued owner email, and "aggregate activation" all belong to the
future aggregate work. They are recorded here as acceptance constraints so the
work that lands first — starting with "keep existing payments refundable when
new sales are off" — does not paint the aggregate into a corner.

Each rule names the current behaviour on `main` where one exists, so a reader
can see what already holds and what is still future work. Nothing here is
implemented ahead of its time; this document is the contract, not scaffolding.

## The separation this PR establishes

New sales and existing payments are now resolved by different questions:

- **New sales** — `getActivePaymentProvider()` / `isPaymentsEnabled()`. Returns
null when the operator has saved the provider as "none", so no buyer can
start a new checkout.
- **Existing payments** — `getPaymentProviderForExistingPayments()`. Refunds,
provider reconciliation, replayed callbacks, and completion of already-started
payment work use this. When new sales are off, it falls back to the last
provider the operator activated (whose credentials stay stored), so money
already captured is never stranded.

The rules below assume that separation holds.

## 1. Failed checkout plus captured money becomes owner review with complete-or-refund choices

A captured charge whose booking cannot be honoured at the charged amount does
not disappear and is not silently refunded. It is surfaced for the operator,
who must be able to either complete it (issue the ticket) or refund it.

*On `main` today:* a signed captured payment that cannot be honoured is kept as
a quantity-0 placeholder and refunded automatically (`storeRefundedBooking` /
`refundAndFail`), or acknowledged as "already handled" when its booking is gone.
There is no owner-review choice yet — the refund is automatic. The
"complete-or-refund" choice is future aggregate work; the current path must
keep refunding safely (covered by this PR's regression tests) so the aggregate
can later replace the automatic refund with a review.

## 2. Completed refunds count immediately while overlapping refunds are blocked until provider totals catch up

A refund that succeeds is recorded at once. A second refund attempt for the same
charge must not pay out twice: it is treated as already-done once the provider's
own refund total confirms it.

*On `main` today:* `tryRefund` treats a payment the provider reports as already
refunded (`isPaymentRefunded`) as success, and each charge carries a
`provider_refunded_at` marker so a later attempt skips the provider call for
charges already returned. This is the current rule and must stay.

## 3. Multiple captured charges require owner review

When a buyer has more than one captured charge for the same booking (a deposit
plus a balance, or charges combined by a merge), the system must not pick a
resolution by default. It surfaces the set for the operator to decide.

*On `main` today:* a merged attendee can carry several references, and the
bulk/single refund paths handle them as a batch without a review step. The
"require owner review" choice is future aggregate work.

## 4. Queued owner email uses the current business address but stored body/buyer facts

When an owner notification is queued and sent later, the recipient address is
read at send time (so it reaches the current operator), but the message body
and the buyer facts it describes come from when the case was raised (so it
describes what actually happened, not a later state).

*On `main` today:* there is no queued owner-email path; notifications are sent
inline. This is future aggregate work.

## 5. Malformed legacy records migrate without invented facts into owner review

A legacy or malformed payment record that cannot be interpreted must not have
facts invented for it. It is carried forward only as far as it can be honestly
read and lands in owner review for a person to resolve.

*On `main` today:* legacy migration copies what is provably there (references,
statuses) and invents nothing; an unreadable record is left for the operator
rather than guessed. This must stay true as the aggregate's migration lands.

## 6. A buyer with a paid booking under review sees "payment received / do not pay again" and gets a stable reload

A buyer whose payment was captured but whose booking is not yet resolved must be
told their payment was received and must not be offered a way to pay again. A
reload of the page must return the same state, not re-charge or re-process.

*On `main` today:* a paid session the ledger already records replays as
success ("payment received") and is never re-processed or re-refunded
(`replaySessionFromLedger`). The explicit "under review" state and its stable
messaging are future aggregate work; the replay safety it depends on is
current and must stay.

## 7. Aggregate activation waits for complete owner case pages/actions

The aggregate is only switched on once every owner-case page and action it
depends on exists and works. No half-enabled state where some payments flow
through the aggregate and some do not.

*On `main` today:* there is no aggregate to activate. This is the gate the
future work must clear before it takes over any payment path.
4 changes: 4 additions & 0 deletions scripts/mutation/equivalent-mutants/shared-m-z.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ src/shared/rest/crud-api.ts:367:31 ?? → || # hydrated map values are record
src/shared/rest/crud-api.ts:577:27 ?? → || # extraRoutes is a route object or undefined
src/shared/rest/resource.ts:158:25 ?? → || # validationError is an ErrorResult object or null; an object is truthy

# paymentsApi.getConfiguredProvider() returns PaymentProviderType | null (a
# validated settings getter, never a falsy non-null string), so ?? and || agree.
src/shared/payments.ts:379:44 ?? → || # getConfiguredProvider() is a provider type or null; never falsy-but-non-null

# request-scoped ambient fallback: scope.current() is T|undefined where T is
# always a truthy container object (never a falsy-non-null value), so
# `?? fallback` and `|| fallback` always agree. (createScopedValue's ?? is NOT
Expand Down
13 changes: 8 additions & 5 deletions src/features/admin/require-provider.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import {
getActivePaymentProvider,
getPaymentProviderForExistingPayments,
type PaymentProvider,
} from "#shared/payments.ts";

/** The active payment provider, or the caller's `onMissing` fallback (a redirect
* or error Response) when none is configured. The refund and refresh POSTs share
* this "need a provider before we touch money" guard instead of each re-checking. */
/** The provider for refunding or refreshing an existing payment, or the
* caller's `onMissing` fallback (a redirect or error Response) when none was
* ever configured. Uses {@link getPaymentProviderForExistingPayments} so an
* operator can still refund and reconcile after switching new sales off. The
* refund and refresh POSTs share this "need a provider before we touch money"
* guard instead of each re-checking. */
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
export const requirePaymentProvider = async <T>(
onMissing: () => T,
): Promise<PaymentProvider | T> => {
const provider = await getActivePaymentProvider();
const provider = await getPaymentProviderForExistingPayments();
return provider ?? onMissing();
};
7 changes: 5 additions & 2 deletions src/features/api/payment-processing/classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { BookingIntent } from "#shared/booking-intent.ts";
import { ErrorCode, logError } from "#shared/logger.ts";
import { verifyPrice } from "#shared/payment-signature.ts";
import {
getActivePaymentProvider,
getPaymentProviderForExistingPayments,
type ValidatedPaymentSession,
} from "#shared/payments.ts";

Expand Down Expand Up @@ -119,7 +119,10 @@ export const classifySessionIntent = async (
export const validatePaidSession = async (
sessionId: string,
): Promise<SessionValidation> => {
const provider = await getActivePaymentProvider();
// An in-flight checkout may complete after the operator switched new sales
// off, so resolve the provider that captured the payment rather than the
// new-sales gate.
const provider = await getPaymentProviderForExistingPayments();
if (!provider) {
logRedirectError(`No payment provider configured (session=${sessionId})`);
return {
Expand Down
14 changes: 9 additions & 5 deletions src/features/api/payment-processing/refunds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from "#shared/logger.ts";
import { sendNtfyError } from "#shared/ntfy.ts";
import {
getActivePaymentProvider,
getPaymentProviderForExistingPayments,
type ValidatedPaymentSession,
} from "#shared/payments.ts";
import { addPendingWork } from "#shared/pending-work.ts";
Expand All @@ -31,16 +31,20 @@ const PRICE_CHANGED_MESSAGE =
"The price for this listing changed while you were completing payment.";

/**
* Resolve the active payment provider. When none is configured, log a
* structured error with the caller's code/detail and return null, so each
* Resolve the provider for refunding or reconciling an existing payment. Falls
* back to the last activated provider when new sales are off, so refunds keep
* working after the operator switches new sales off. When none is configured,
* log a structured error with the caller's code/detail and return null, so each
* caller can pick its own fallback (a false, a 400, ...).
*/
export const getPaymentProviderOrLog = async (
code: ErrorCodeType,
detail: string,
listingId?: number,
): Promise<Awaited<ReturnType<typeof getActivePaymentProvider>>> => {
const provider = await getActivePaymentProvider();
): Promise<
Awaited<ReturnType<typeof getPaymentProviderForExistingPayments>>
> => {
const provider = await getPaymentProviderForExistingPayments();
if (!provider) logError({ code, detail, listingId });
return provider;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
10 changes: 6 additions & 4 deletions src/features/api/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { ErrorCode, logDebug, logError } from "#shared/logger.ts";
import { WEBHOOK_SIGNATURE_HEADERS } from "#shared/payment-providers.ts";
import { getPaymentWebhookUrl } from "#shared/payment-webhook-url.ts";
import {
getActivePaymentProvider,
getPaymentProviderForExistingPayments,
Comment thread
stefan-burke marked this conversation as resolved.
type ValidatedPaymentSession,
type WebhookEvent,
} from "#shared/payments.ts";
Expand Down Expand Up @@ -237,7 +237,9 @@ const handlePaymentSuccess = (request: Request): Promise<Response> => {
const logCancelError = paymentSessionErrorLogger("cancel");

const handlePaymentCancel = withSessionId(async (sid) => {
const provider = await getActivePaymentProvider();
// A buyer who cancels may do so after the operator switched new sales off, so
// resolve the provider that captured the payment rather than the new-sales gate.
const provider = await getPaymentProviderForExistingPayments();
Comment thread
stefan-burke marked this conversation as resolved.
if (!provider) {
logCancelError(`No provider configured (session=${sid})`);
return paymentErrorResponse("Payment provider not configured");
Expand Down Expand Up @@ -320,7 +322,7 @@ const authenticateWebhook = async (
| Response
| {
provider: NonNullable<
Awaited<ReturnType<typeof getActivePaymentProvider>>
Awaited<ReturnType<typeof getPaymentProviderForExistingPayments>>
>;
listing: WebhookEvent;
}
Expand Down Expand Up @@ -376,7 +378,7 @@ const authenticateWebhook = async (
const handlePaymentWebhook = async (request: Request): Promise<Response> => {
// Read raw body bytes FIRST, before any async work. The Bunny Edge runtime
// can garbage-collect the underlying request body resource during awaits
// (e.g. dynamic imports in getActivePaymentProvider), causing "BadResource:
// (e.g. dynamic imports in getPaymentProviderForExistingPayments), causing "BadResource:
// Cannot read body as underlying resource unavailable" errors.
const payloadBytes = new Uint8Array(await request.arrayBuffer());
const payload = new TextDecoder().decode(payloadBytes);
Expand Down
1 change: 1 addition & 0 deletions src/features/settings-bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const PUBLIC_NAV_SETTINGS: readonly string[] = [
*/
const PAYMENT_SETTINGS: readonly string[] = [
CONFIG_KEYS.PAYMENT_PROVIDER,
Comment thread
stefan-burke marked this conversation as resolved.
CONFIG_KEYS.LAST_ACTIVE_PAYMENT_PROVIDER,
CONFIG_KEYS.COUNTRY,
CONFIG_KEYS.BOOKING_FEE,
CONFIG_KEYS.STRIPE_SECRET_KEY,
Expand Down
23 changes: 22 additions & 1 deletion src/shared/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ import type {
SuperuserChoice,
Theme,
} from "#shared/types.ts";
import { isSuperuserChoice } from "#shared/types.ts";
import { isPaymentProvider, isSuperuserChoice } from "#shared/types.ts";
import { appleWallet } from "#shared/wallets/apple-wallet-settings.ts";
import { googleWallet } from "#shared/wallets/google-wallet-settings.ts";
import type { EmailContent } from "#templates/email/shared.ts";
Expand Down Expand Up @@ -243,6 +243,14 @@ const settingsBase = {
// --- Google Wallet ---
googleWallet: googleWallet.createReadSettings(snap as (k: string) => string),
invalidateCache,
/** The provider that captured payments before new sales were switched off.
* Used to refund, reconcile, replay, and complete payments that already
* exist while new sales are disabled; null when no provider was ever
* activated. */
get lastActivePaymentProvider(): PaymentProviderType | null {
const value = snap("last_active_payment_provider");
return isPaymentProvider(value) ? value : null;
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
},
get listingColumnLayout(): TableLayout<ListingColumnKey> {
return configurableTableLayouts.listing.parse(snap("listing_column_order"));
},
Expand Down Expand Up @@ -444,8 +452,21 @@ const settingsBase = {
await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, v);
data.payment_provider = v;
data.payment_provider_setting = v;
// Remember the activated provider so existing payments stay serviceable
// after new sales are later switched off.
await writeRaw(CONFIG_KEYS.LAST_ACTIVE_PAYMENT_PROVIDER, v);
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
data.last_active_payment_provider = v;
},
setPaymentProviderNone: async (): Promise<void> => {
// Remember the provider being switched off (if any) before clearing it,
// so refunds, replayed callbacks, and in-flight completions of payments
// that already exist keep resolving the provider that captured them. A
// second "none" save leaves the remembered provider in place.
const switchedOff = data.payment_provider;
if (switchedOff) {
await writeRaw(CONFIG_KEYS.LAST_ACTIVE_PAYMENT_PROVIDER, switchedOff);
data.last_active_payment_provider = switchedOff;
}
await writeRaw(CONFIG_KEYS.PAYMENT_PROVIDER, "none");
data.payment_provider = null;
data.payment_provider_setting = "none";
Expand Down
54 changes: 44 additions & 10 deletions src/shared/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import type { ContactInfo, PaymentProviderType } from "#shared/types.ts";
export const paymentsApi = {
getConfiguredProvider: (): PaymentProviderType | null =>
settings.paymentProvider,
/** The provider that captured payments before new sales were switched off;
* used to keep existing payments refundable and completable while new
* sales are disabled. */
getLastConfiguredProvider: (): PaymentProviderType | null =>
settings.lastActivePaymentProvider,
};

/** Re-export from types.ts (canonical definition) */
Expand Down Expand Up @@ -335,14 +340,43 @@ const providerLoaders: Record<
(await import("#shared/sumup-provider.ts")).sumupPaymentProvider,
};

export const getActivePaymentProvider =
async (): Promise<PaymentProvider | null> => {
const providerType = paymentsApi.getConfiguredProvider();
if (!providerType) {
logDebug("Payment", "No payment provider configured in settings");
return null;
}
/** Resolve a provider type, log it, and load it. `label` carries which question
* is being answered (" for new sales" / " for existing payments") so the log
* names the path; `onMissing` lets a caller log a specific message when no
* provider resolves. The single load path both resolvers share. */
const resolveProvider = async (
resolveType: () => PaymentProviderType | null,
label: string,
onMissing?: () => void,
): Promise<PaymentProvider | null> => {
const providerType = resolveType();
if (!providerType) {
onMissing?.();
return null;
}
logDebug("Payment", `Resolving payment provider${label}: ${providerType}`);
return providerLoaders[providerType]();
};

logDebug("Payment", `Resolving payment provider: ${providerType}`);
return await providerLoaders[providerType]();
};
export const getActivePaymentProvider = (): Promise<PaymentProvider | null> =>
resolveProvider(paymentsApi.getConfiguredProvider, "", () =>
logDebug("Payment", "No payment provider configured in settings"),
);

/**
* Resolve the provider for work on payments that already exist — refunds,
* provider reconciliation, replayed callbacks, and completion of an
* already-started payment. New sales use {@link getActivePaymentProvider};
* this falls back to the last provider the operator activated when new sales
* are off ("none"), whose credentials stay stored, so disabling new sales
* never strands money already captured. Returns null only when no provider was
* ever configured.
*/
export const getPaymentProviderForExistingPayments =
(): Promise<PaymentProvider | null> =>
resolveProvider(
() =>
paymentsApi.getConfiguredProvider() ??
paymentsApi.getLastConfiguredProvider(),
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
Comment thread
stefan-burke marked this conversation as resolved.
Outdated
" for existing payments",
);
1 change: 1 addition & 0 deletions src/shared/settings/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const CONFIG_KEY_NAMES = [
"GOOGLE_WALLET_SERVICE_ACCOUNT_KEY",
"HEADER_IMAGE_URL",
"HOMEPAGE_TEXT",
"LAST_ACTIVE_PAYMENT_PROVIDER",
"LATEST_SCRIPT_VERSION",
"LATEST_SCRIPT_VERSION_NAME",
"LISTING_COLUMN_ORDER",
Expand Down
Loading