Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/ci-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ jobs:
run: bun run cms:migrate:status
- name: Apply demo seed
run: psql "$DATABASE_URL" -f supabase/seed.sql -v ON_ERROR_STOP=1
- name: Verify donation fee replay
run: psql "$DATABASE_URL" -f tests/integration/supabase/donation-fee-replay-verification.sql -v ON_ERROR_STOP=1
- name: Verify seed counts
run: |
profile_count=$(psql "$DATABASE_URL" -t -A -c "SELECT COUNT(*) FROM public.profiles;" | tr -d '[:space:]')
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/qa-smoke-preview-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ jobs:
if: steps.scope.outputs.any == 'true'
env:
CLAUDE_QA_ROUTINE_WEBHOOK_URL: ${{ secrets.CLAUDE_QA_ROUTINE_WEBHOOK_URL }}
QA_TEST_EMAIL: ${{ secrets.QA_TEST_EMAIL }}
QA_TEST_PASSWORD: ${{ secrets.QA_TEST_PASSWORD }}
QA_TEST_EMAIL: ${{ secrets.QA_PREVIEW_TEST_EMAIL && secrets.QA_PREVIEW_TEST_PASSWORD && secrets.QA_PREVIEW_TEST_EMAIL || secrets.QA_TEST_EMAIL }}
QA_TEST_PASSWORD: ${{ secrets.QA_PREVIEW_TEST_EMAIL && secrets.QA_PREVIEW_TEST_PASSWORD && secrets.QA_PREVIEW_TEST_PASSWORD || secrets.QA_TEST_PASSWORD }}
VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET }}
VERCEL_ADMIN_PROJECT_ID: ${{ secrets.VERCEL_ADMIN_PROJECT_ID }}
VERCEL_DONOR_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_DONOR_AUTOMATION_BYPASS_SECRET }}
Expand Down Expand Up @@ -175,11 +175,12 @@ jobs:
- name: Validate required preview smoke secrets
if: steps.scope.outputs.any == 'true'
env:
QA_PREVIEW_CREDENTIALS_PARTIAL: ${{ (secrets.QA_PREVIEW_TEST_EMAIL != '' && secrets.QA_PREVIEW_TEST_PASSWORD == '') || (secrets.QA_PREVIEW_TEST_EMAIL == '' && secrets.QA_PREVIEW_TEST_PASSWORD != '') }}
ADMIN_AFFECTED: ${{ steps.scope.outputs.admin }}
DONOR_AFFECTED: ${{ steps.scope.outputs.donor }}
MISSIONARY_AFFECTED: ${{ steps.scope.outputs.missionary }}
QA_TEST_EMAIL: ${{ secrets.QA_TEST_EMAIL }}
QA_TEST_PASSWORD: ${{ secrets.QA_TEST_PASSWORD }}
QA_TEST_EMAIL: ${{ secrets.QA_PREVIEW_TEST_EMAIL && secrets.QA_PREVIEW_TEST_PASSWORD && secrets.QA_PREVIEW_TEST_EMAIL || secrets.QA_TEST_EMAIL }}
QA_TEST_PASSWORD: ${{ secrets.QA_PREVIEW_TEST_EMAIL && secrets.QA_PREVIEW_TEST_PASSWORD && secrets.QA_PREVIEW_TEST_PASSWORD || secrets.QA_TEST_PASSWORD }}
VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET }}
VERCEL_ADMIN_PROJECT_ID: ${{ secrets.VERCEL_ADMIN_PROJECT_ID }}
VERCEL_DONOR_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_DONOR_AUTOMATION_BYPASS_SECRET }}
Expand All @@ -192,6 +193,11 @@ jobs:
run: |
set -euo pipefail

if [[ "${QA_PREVIEW_CREDENTIALS_PARTIAL}" == "true" ]]; then
echo "::error::Both QA_PREVIEW_TEST_EMAIL and QA_PREVIEW_TEST_PASSWORD must be configured together."
exit 1
fi

missing=()

require_secret() {
Expand Down Expand Up @@ -434,8 +440,8 @@ jobs:
QA_ADMIN_BASE_URL: ${{ steps.deploy_admin.outputs.url }}
QA_DONOR_BASE_URL: ${{ steps.deploy_donor.outputs.url }}
QA_MISSIONARY_BASE_URL: ${{ steps.deploy_missionary.outputs.url }}
QA_TEST_EMAIL: ${{ secrets.QA_TEST_EMAIL }}
QA_TEST_PASSWORD: ${{ secrets.QA_TEST_PASSWORD }}
QA_TEST_EMAIL: ${{ secrets.QA_PREVIEW_TEST_EMAIL && secrets.QA_PREVIEW_TEST_PASSWORD && secrets.QA_PREVIEW_TEST_EMAIL || secrets.QA_TEST_EMAIL }}
QA_TEST_PASSWORD: ${{ secrets.QA_PREVIEW_TEST_EMAIL && secrets.QA_PREVIEW_TEST_PASSWORD && secrets.QA_PREVIEW_TEST_PASSWORD || secrets.QA_TEST_PASSWORD }}
VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET }}
VERCEL_DONOR_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_DONOR_AUTOMATION_BYPASS_SECRET }}
VERCEL_MISSIONARY_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_MISSIONARY_AUTOMATION_BYPASS_SECRET }}
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ This repo uses **Bun** pinned in root `package.json` `packageManager` and `.bun-

### Monorepo Workspace Contract

Vercel installs with `bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile` to keep the build-image package manager on the workspace pin. See [CI toolchain guidance](docs/ci.md#bun-toolchain).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Bun workspaces + Turborepo:

```text
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/vercel.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"installCommand": "bun install --cwd ../.. --frozen-lockfile",
"installCommand": "bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile",
"buildCommand": "cd ../.. && bun run build:admin",
"ignoreCommand": "node ../../scripts/vercel/should-ignore-build.mjs admin",
"git": {
Expand Down
2 changes: 1 addition & 1 deletion apps/donor/vercel.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"installCommand": "bun install --cwd ../.. --frozen-lockfile",
"installCommand": "bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile",
"buildCommand": "cd ../.. && bun run build:donor",
"ignoreCommand": "node ../../scripts/vercel/should-ignore-build.mjs donor",
"git": {
Expand Down
2 changes: 1 addition & 1 deletion apps/missionary/vercel.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"installCommand": "bun install --cwd ../.. --frozen-lockfile",
"installCommand": "bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile",
"buildCommand": "cd ../.. && bun run build:missionary",
"ignoreCommand": "node ../../scripts/vercel/should-ignore-build.mjs missionary",
"git": {
Expand Down
2 changes: 1 addition & 1 deletion docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The exact live required-check sets are recorded only in § Branch protection.
- **Pinned version:** root `package.json` `packageManager` and `.bun-version` (currently `bun@1.4.0`, stable only — never canary).
- **Runtime vs package manager:** Bun is the install/script runner. Next.js apps still execute on Node.js (Vercel project `nodeVersion` is `24.x`). Do not pass `bun --bun`, and do not set `bunVersion` in `apps/*/vercel.json`.
- **Vercel Functions Bun 1.4 is a separate runtime:** [Vercel's Bun 1.4 changelog](https://vercel.com/changelog/bun-1-4-is-now-available-in-vercel-functions) documents opting **Functions and Middleware** onto Bun via `"bunVersion": "1.4.x"`. That is not how you pin the package manager. `"1.x"` still selects Bun 1.3.14 on Functions. Next.js on the Bun runtime also requires `bun run --bun next dev|build` ([runtime docs](https://vercel.com/docs/functions/runtimes/bun)). Core stays on the Node path (`next dev` / `next build` / `next start`) because Payload, Stripe, Supabase SSR, and eve-runtime are validated there; Vercel treats the Bun Functions runtime as an explicit breaking-change opt-in.
- **Vercel install vs GitHub install:** App `installCommand` is `bun install --cwd ../.. --frozen-lockfile` (workspace root, frozen lockfile). That matches [Vercel package-manager detection](https://vercel.com/docs/package-managers) for `bun.lock` (`bun install`, not `bun ci`, and not `bun install --save-text-lockfile`). GitHub Actions keeps `bun ci --no-cache --backend=copyfile` for the portable file-copy backend. [Pinning a Bun version for Vercel _builds_](https://vercel.com/kb/guide/how-to-pin-a-specific-bun-version-for-vercel-builds) is `bunx bun@1.4.0 install`; Corepack does **not** pin Bun (it is for pnpm/Yarn). Do not change the install command unless a deploy proves the build-image Bun cannot read this `lockfileVersion` 1 file.
- **Vercel install vs GitHub install:** App `installCommand` is `bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile` (workspace root, pinned package manager, frozen lockfile), following [Vercel's documented build pin](https://vercel.com/kb/guide/how-to-pin-a-specific-bun-version-for-vercel-builds). Preview deployment `dpl_8p7c7tAFVzvdLZY5t4FB8NuTxdCx` proved that the build image's Bun 1.3.14 cannot install the current lockfile without drift. Pinning installation does not opt Functions into the Bun runtime. GitHub Actions keeps `bun ci --no-cache --backend=copyfile` for the portable file-copy backend. Corepack does **not** pin Bun (it is for pnpm/Yarn).
- **GitHub Actions:** `ci.yml`, `ci-integration.yml`, and `qa-smoke-preview-deploy.yml` set `env.BUN_VERSION` to that exact version; every first-party `oven-sh/setup-bun@v2` step uses `bun-version: ${{ env.BUN_VERSION }}`.
- **Workflow pin verification:** `verify:bun-version` parses workflow YAML with Bun's built-in parser, so it also works before dependencies are installed. It checks each setup step's own `with.bun-version` and the workflow/job/step environment in scope; comments, run-script text, unrelated inputs, and another job's environment cannot satisfy the pin contract. Quoted scalars and YAML aliases remain supported.
- **Live runtime verification:** `verify:vercel-build-controls` reads all three Vercel projects and requires `nodeVersion: "24.x"` with `bunVersion` absent or `null`. Any explicit Bun runtime value fails, even if source-controlled `vercel.json` files still select Node. This verifier only reads project settings.
Expand Down
8 changes: 6 additions & 2 deletions docs/guides/features/guest-giving-cover-fees.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,12 @@ Giving rows keep stored extras, including `payment_method`, because charged
cents in `p_amount` do not preserve method.

HTTP `POST /api/donate` replay of empty/legacy `{}` with matching charged
cents continues so the saga can persist the current extras onto empty. A
stored full quote that differs still returns `409`.
cents continues with the original empty extras and fee-related provider parameters. It must
not attach the current quote: the provider may have created a PaymentIntent
before the database completion write failed, and changing metadata or payment
methods under the same idempotency key would break that retry. A stored full
quote that differs still returns `409`; newly quoted gifts still persist their
quote at intake.

## Related

Expand Down
53 changes: 46 additions & 7 deletions docs/guides/operations/donation-saga-outbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ dollars. Gift processing-fee policy recomputes charged cents from `cover_fees`
and `payment_method` before `begin_donation_saga`. `p_amount` is still charged
cents.

First-shot processing from that POST persists quote extras onto
`donation_saga_outbox.fee_extras` and may attach them to PaymentIntent
metadata (`gift_amount_cents`, `cover_fees`, `payment_method`,
`cover_amount_cents`, `estimated_fee_cents`) without overriding `donation_id`.
Gift intake persists quote extras on the outbox in the same transaction that
creates the donation (`gift_amount_cents`, `cover_fees`, `payment_method`,
`cover_amount_cents`, `estimated_fee_cents`). Processing copies that quote to
PaymentIntent metadata without overriding donation identity.

Recovery and batch workers (`processDueDonationSagaOutboxEvents`, admin
replay) load stored `fee_extras` before PaymentIntent create. A lookup or
Expand All @@ -53,11 +53,18 @@ stored `donations.amount` and `donation_saga_outbox.fee_extras` and:
- returns `409` when charged cents match but a stored full fee quote differs
from the current quote
- continues when charged cents match and stored extras are empty/legacy `{}`
(or otherwise absent), passing the current quote extras so the saga can
persist onto empty before claim
(or otherwise absent), omitting fee metadata so the saga preserves its original
fee-related provider parameters and leaves empty stored extras unchanged
- returns `500` when stored extras cannot be loaded or are malformed
- processes the existing outbox without rewriting matching stored extras

An empty legacy quote is not evidence that Stripe has never seen the request.
The provider may have created a PaymentIntent before a database completion write
failed. Hydrating that retry with new fee metadata or payment-method types changes
its parameters under the same idempotency key and can strand the saga. This
replaces the earlier instruction to fill legacy extras; modern stored quotes and
first-shot intake persistence remain unchanged.

Verification:

1. POST the same idempotency key with a different charged amount → `409`.
Expand All @@ -66,12 +73,44 @@ Verification:
3. POST the same key with matching charged cents and matching extras → `200`
and no rewrite of stored extras.
4. POST the same key with matching charged cents and stored `fee_extras: {}`
→ `200` and saga called with the current quote extras.
→ `200` (or the existing processing response) with no fee metadata supplied
to the saga and no stored-extras rewrite.
5. POST `currency=eur` → `400` before `begin_donation_saga`.
6. First-shot card Gift PaymentIntents use `payment_method_types: ["card"]`
and omit `automatic_payment_methods`.
7. Recovery of stored ACH extras binds `payment_method_types: ["us_bank_account"]`
even when the worker omits extras.
8. Simulate provider success followed by a failed completion write, then POST
the same legacy gift again with the original actor and customer → the same
PaymentIntent completes, both provider requests have identical parameters,
and stored extras remain `{}`. Covered by
`packages/api/tests/unit/donate-post-charge.test.ts` using the real saga with
deterministic database and provider boundaries.

### Replay migration rollout

Apply `20261001053404_preserve_donation_saga_fee_replay.sql` before deploying
this HTTP replay path. The service-role-only fee-aware claim compares quotes
under a row lock before incrementing attempts. Conflicting requests return
`409` without recording a donation failure. The fee-extras trigger preserves
both full quotes and legacy absence after processing begins, including when an
older in-flight writer tries to hydrate the row. Existing worker claim and
recovery RPCs remain compatible. Keep the protective trigger during an
application rollback; pause donation processors before any database rollback.

### Separate actor-recovery limitation

This fee repair does not establish actor-independent provider recovery. The
background worker supplies `WORKFLOW_SYSTEM_ACTOR_ID`, while HTTP processing
supplies the authenticated actor; the saga currently copies that caller into
PaymentIntent `metadata.user_id`. A different retry actor can still change
provider parameters under the same idempotency key, even with an unchanged full
fee quote. The outbox does not retain the first provider caller. The intake audit
actor alone cannot reconstruct it because a worker may have sent the first
provider request. Do not guess historical attribution, remove it, or invent a
new payment identity to make recovery pass. Immutable first-provider identity
and an evidence-backed legacy recovery rule require a separate source-recovery
change; the regression above proves the same-actor HTTP fee replay only.

Staff `POST /api/donations` does not run Gift processing-fee policy. That path
already sends charged cents as `p_amount`.
Expand Down
10 changes: 5 additions & 5 deletions docs/ops/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ the [Bun Functions runtime](https://vercel.com/docs/functions/runtimes/bun)
(`"1.4.x"` or `"1.x"`), not the package manager. These projects stay on the
Next.js + Node 24.x Functions runtime.

| Vercel project | `installCommand` | `buildCommand` | `ignoreCommand` |
| -------------- | ------------------------------------------- | -------------------------------------- | -------------------------------------------------------------- |
| `admin` | `bun install --cwd ../.. --frozen-lockfile` | `cd ../.. && bun run build:admin` | `node ../../scripts/vercel/should-ignore-build.mjs admin` |
| `donor` | `bun install --cwd ../.. --frozen-lockfile` | `cd ../.. && bun run build:donor` | `node ../../scripts/vercel/should-ignore-build.mjs donor` |
| `missionary` | `bun install --cwd ../.. --frozen-lockfile` | `cd ../.. && bun run build:missionary` | `node ../../scripts/vercel/should-ignore-build.mjs missionary` |
| Vercel project | `installCommand` | `buildCommand` | `ignoreCommand` |
| -------------- | ------------------------------------------------------ | -------------------------------------- | -------------------------------------------------------------- |
| `admin` | `bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile` | `cd ../.. && bun run build:admin` | `node ../../scripts/vercel/should-ignore-build.mjs admin` |
| `donor` | `bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile` | `cd ../.. && bun run build:donor` | `node ../../scripts/vercel/should-ignore-build.mjs donor` |
| `missionary` | `bunx bun@1.4.0 install --cwd ../.. --frozen-lockfile` | `cd ../.. && bun run build:missionary` | `node ../../scripts/vercel/should-ignore-build.mjs missionary` |

Vercel runs `ignoreCommand` from the app root. The helper returns `0` to skip
the build and `1` to continue the build, matching Vercel's ignored-build
Expand Down
17 changes: 15 additions & 2 deletions docs/qa/pr-preview-smoke.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,16 @@ Production.
preview URL payload for Claude QA handoff. Missing webhook configuration is
a skip, not a failure.

## Preview test identity

Use a dedicated identity in the isolated preview datasource. The workflow
prefers `QA_PREVIEW_TEST_EMAIL` / `QA_PREVIEW_TEST_PASSWORD` and falls back to the
existing QA pair when the preview-specific pair is absent. The suite still
receives `QA_TEST_EMAIL` / `QA_TEST_PASSWORD`, so credential redaction is
unchanged. The identity must have legitimate access to the three tested
surfaces and the associated donor/missionary fixture records; login success
alone does not establish that access.

## Required Secrets

Configure these GitHub repository secrets:
Expand All @@ -81,8 +91,9 @@ Configure these GitHub repository secrets:
- `VERCEL_ADMIN_PROJECT_ID`
- `VERCEL_DONOR_PROJECT_ID`
- `VERCEL_MISSIONARY_PROJECT_ID`
- `QA_TEST_EMAIL`
- `QA_TEST_PASSWORD`
- `QA_PREVIEW_TEST_EMAIL`
- `QA_PREVIEW_TEST_PASSWORD`
- Legacy fallback: `QA_TEST_EMAIL` / `QA_TEST_PASSWORD`
- `VERCEL_ADMIN_AUTOMATION_BYPASS_SECRET`
- `VERCEL_DONOR_AUTOMATION_BYPASS_SECRET`
- `VERCEL_MISSIONARY_AUTOMATION_BYPASS_SECRET`
Expand Down Expand Up @@ -186,3 +197,5 @@ development and production deployments remain available.
- [ ] No production deployment was requested
- [ ] No secrets, credentials, tokens, cookies, reports, or bypass URLs were
posted

Configure both Preview credential secrets together. A partial Preview pair fails validation; the legacy QA pair is used only when both Preview secrets are absent.
Loading
Loading