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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ cp .env.example .env

**Note:** _To avoid modifying the original file, you can create `.env.local` and add overrides for the variables that are specific to your local environment. This approach allows you to keep your customizations separate from the default values._

### Configure Stripe (optional)

If you'll be working on payment-related features, follow the
[Stripe setup guide in TESTING.md](TESTING.md#stripe--first-time-setup) to
configure your Stripe keys and webhook listener.

### Run the tests

Testing the initialization is done correctly.
Expand Down
128 changes: 121 additions & 7 deletions TESTING.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,79 @@
# Testing

## Testing Stripe
## Stripe — first-time setup

You need two things in `.env.local` before the API can talk to Stripe: a
**secret key** (`STRIPE_SECRET_KEY`) and a **webhook signing secret**
(`STRIPE_WEBHOOK_SECRET`). Both are personal to you and easy to obtain.

### 1. Get a personal Stripe test account

If you don't already have one, sign up at <https://dashboard.stripe.com/register>.
You don't need to activate live mode or submit any business details — test
mode works on a fresh account out of the box.

### 2. Get a secret key

> **⚠️ Heads up:** production and staging run on restricted keys
> (`rk_test_...`), not unrestricted secret keys (`sk_test_...`). We recommend
> using a restricted key for local development too, especially when working on
> new Stripe features. This way any missing permission shows up on your machine
> first — rather than surprising everyone after deploy. See
> [Using a restricted key](#using-a-restricted-key-recommended-for-stripe-work)
> below for setup instructions.

1. Open <https://dashboard.stripe.com/test/apikeys>
2. Under **Standard keys**, copy your `sk_test_...` secret key
3. Paste it into `.env.local`:

```shell
STRIPE_SECRET_KEY=sk_test_...
```

That's enough to get the API talking to Stripe locally.

Install `stripe-cli` from <https://stripe.com/docs/stripe-cli>
### 3. Start the Stripe webhook listener

In one shell forward stripe webhook events to the locally running API
The easiest way to receive Stripe webhook events locally is via the
`stripe-webhook` Docker service. It runs the Stripe CLI, forwards events to
your locally running API, and **automatically writes the webhook signing
secret** (`STRIPE_WEBHOOK_SECRET`) into `.env.local` on first start — no
manual copy-paste needed.

```shell
yarn stripe:listen-webhook
docker compose up stripe-webhook -d
```

:information*source: When you start the listening process you'll be given an signing secret `whsec*....`. Place that secret in `.env.local` as:
That's it. Once the container starts you should see `Webhook secret written
to .env.local` in the logs:

```shell
STRIPE_SECRET_KEY=sk_test_.....
STRIPE_WEBHOOK_SECRET=whsec_.....
docker compose logs stripe-webhook
```

Your `.env.local` will now contain both keys:

```shell
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_... # auto-populated by the container
```

Leave the container running while you work on Stripe-related features — it
forwards real Stripe events to `http://host.docker.internal:5010`.

> **Alternative (without Docker):** install `stripe-cli` from
> <https://stripe.com/docs/stripe-cli> and run:
>
> ```shell
> yarn stripe:listen-webhook
> ```
>
> Copy the `whsec_...` secret it prints on startup into `.env.local` manually.

## Testing Stripe

Once setup is complete, the day-to-day flow is:

In another shell start the API process

```shell
Expand All @@ -36,3 +93,60 @@ stripe events resend evt_3MlHGFKApGjVGa9t0GUhYsKB
```

Important - From the the Stripe CLI docs: Triggering some events like payment_intent.succeeded or payment_intent.canceled will also send you a payment_intent.created event for completeness.

## Using a restricted key (recommended for Stripe work)

If you're adding or changing Stripe functionality, swap your secret key for a
restricted one so your local environment matches prod:

1. Open <https://dashboard.stripe.com/test/apikeys>
2. Scroll to **Restricted keys** → **Create restricted key**
3. Give it a name like `local-dev-<your-name>`
4. **Leave every permission unchecked for now** — the next step will tell you
exactly which ones to enable
5. Click **Create key** and copy the `rk_test_...` value
6. Replace the key in `.env.local`:

```shell
STRIPE_SECRET_KEY=rk_test_...
```

### Discover which permissions your key needs

You'll do this in two passes — list everything, tick the boxes, then verify.

**1. List every permission the codebase needs:**

```shell
yarn stripe:check-permissions --list-all
```

This prints the full inventory of Stripe permissions the codebase uses,
derived from the `StripeApiClient` gateway in
[apps/api/src/stripe/stripe-api-client.ts](apps/api/src/stripe/stripe-api-client.ts).
**No API key required for this command** — it's a static list of declarations,
safe to run before you've configured anything. The output shows each
dashboard toggle you need to enable, along with the gateway functions that
depend on it.

**2. Tick the boxes in the dashboard.** Open
<https://dashboard.stripe.com/test/apikeys>, edit your restricted key, and
enable the permissions from the output. Save.

**3. Verify your key:**

```shell
yarn stripe:check-permissions
```

This time the script makes real API calls to confirm the key has each scope.
You should see `OK — no missing Stripe permissions detected on this key.` If
anything is missing, the output shows only the gaps — go back to the
dashboard, tick those, save, re-run.

> If a row says `(unmapped — find in dashboard)` instead of a section name,
> Stripe added a permission the script's label table doesn't know about. The
> function name and slug still tell you what to enable; please add the entry
> to `PERMISSION_LABELS` in
> [scripts/stripe/probe-permissions.ts](scripts/stripe/probe-permissions.ts)
> in the same PR so the next person doesn't hit the same gap.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mockDeep } from 'jest-mock-extended'
import { STRIPE_CLIENT_TOKEN } from '@golevelup/nestjs-stripe'

import { StripeService } from '../stripe/stripe.service'
import { StripeApiClient } from '../stripe/stripe-api-client'
import { PersonService } from '../person/person.service'
import { CampaignService } from '../campaign/campaign.service'
import { DonationsService } from '../donations/donations.service'
Expand All @@ -33,6 +34,7 @@ describe('RecurringDonationController', () => {
useValue: mockDeep<HttpService>(),
},
StripeService,
{ provide: StripeApiClient, useValue: mockDeep<StripeApiClient>() },
PersonService,
{ provide: CampaignService, useValue: {} },
DonationsService,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/stripe/events/stripe-payment.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { TemplateService } from '../../email/template.service'
import type { PaymentWithDonation } from '../../donations/types/donation'
import { DonationsService } from '../../donations/donations.service'
import { StripeService } from '../stripe.service'
import { StripeApiClient } from '../stripe-api-client'
import { ExportService } from '../../export/export.service'

const defaultStripeWebhookEndpoint = '/stripe/webhook'
Expand Down Expand Up @@ -146,6 +147,7 @@ describe('StripePaymentService', () => {
PersonService,
RecurringDonationService,
StripeService,
{ provide: StripeApiClient, useValue: mockDeep<StripeApiClient>() },
DonationsService,
ExportService,
{
Expand Down
166 changes: 166 additions & 0 deletions apps/api/src/stripe/stripe-api-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { InjectStripeClient } from '@golevelup/nestjs-stripe'
import { Injectable } from '@nestjs/common'
import Stripe from 'stripe'

/**
* Gateway for the Stripe SDK. Every Stripe API call in this codebase should go
* through this class — no other file should call `stripeClient.X.Y(...)` directly.
*
* Why this exists:
* 1. Single grep target for "what does this app do with Stripe."
* 2. Single place to add cross-cutting concerns (logging, error mapping).
* 3. Probe-able in isolation: a CI script can instantiate this class with a
* test-mode restricted key and call every method to verify the key has all
* required scopes — catching local-vs-prod permission drift before deploy.
*
* Methods are intentionally thin — orchestration, validation, idempotency-key
* generation, and DB lookups belong in StripeService, not here. Each method
* forwards to exactly one Stripe SDK call and accepts Stripe SDK types directly.
*
* Signature rule: every method mirrors the underlying SDK signature as
* `(id?, params?, options?: Stripe.RequestOptions)`. The `options` slot is
* always present so callers can pass `idempotencyKey`, `stripeAccount`,
* per-call `timeout`, etc. without having to edit the gateway first.
*/
@Injectable()
export class StripeApiClient {
constructor(@InjectStripeClient() private readonly stripe: Stripe) {}

// SetupIntents
createSetupIntent(params: Stripe.SetupIntentCreateParams, options?: Stripe.RequestOptions) {
return this.stripe.setupIntents.create(params, options)
}
updateSetupIntent(
id: string,
params: Stripe.SetupIntentUpdateParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.setupIntents.update(id, params, options)
}
cancelSetupIntent(
id: string,
params?: Stripe.SetupIntentCancelParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.setupIntents.cancel(id, params, options)
}
retrieveSetupIntent(
id: string,
params?: Stripe.SetupIntentRetrieveParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.setupIntents.retrieve(id, params, options)
}

// PaymentIntents
createPaymentIntent(params: Stripe.PaymentIntentCreateParams, options?: Stripe.RequestOptions) {
return this.stripe.paymentIntents.create(params, options)
}
updatePaymentIntent(
id: string,
params: Stripe.PaymentIntentUpdateParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.paymentIntents.update(id, params, options)
}
cancelPaymentIntent(
id: string,
params?: Stripe.PaymentIntentCancelParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.paymentIntents.cancel(id, params, options)
}
retrievePaymentIntent(
id: string,
params?: Stripe.PaymentIntentRetrieveParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.paymentIntents.retrieve(id, params, options)
}

// PaymentMethods
listPaymentMethods(params: Stripe.PaymentMethodListParams, options?: Stripe.RequestOptions) {
return this.stripe.paymentMethods.list(params, options)
}
attachPaymentMethod(
id: string,
params: Stripe.PaymentMethodAttachParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.paymentMethods.attach(id, params, options)
}

// Customers
listCustomers(params: Stripe.CustomerListParams, options?: Stripe.RequestOptions) {
return this.stripe.customers.list(params, options)
}
createCustomer(params: Stripe.CustomerCreateParams, options?: Stripe.RequestOptions) {
return this.stripe.customers.create(params, options)
}

// Products
searchProducts(params: Stripe.ProductSearchParams, options?: Stripe.RequestOptions) {
return this.stripe.products.search(params, options)
}
createProduct(params: Stripe.ProductCreateParams, options?: Stripe.RequestOptions) {
return this.stripe.products.create(params, options)
}

// Subscriptions
createSubscription(params: Stripe.SubscriptionCreateParams, options?: Stripe.RequestOptions) {
return this.stripe.subscriptions.create(params, options)
}
cancelSubscription(
id: string,
params?: Stripe.SubscriptionCancelParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.subscriptions.cancel(id, params, options)
}
retrieveSubscription(
id: string,
params?: Stripe.SubscriptionRetrieveParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.subscriptions.retrieve(id, params, options)
}
listSubscriptions(params?: Stripe.SubscriptionListParams, options?: Stripe.RequestOptions) {
return this.stripe.subscriptions.list(params, options)
}

// Prices
listPrices(params: Stripe.PriceListParams, options?: Stripe.RequestOptions) {
return this.stripe.prices.list(params, options)
}

// Checkout
createCheckoutSession(
params: Stripe.Checkout.SessionCreateParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.checkout.sessions.create(params, options)
}

// Refunds
createRefund(params: Stripe.RefundCreateParams, options?: Stripe.RequestOptions) {
return this.stripe.refunds.create(params, options)
}

// Charges
retrieveCharge(
id: string,
params?: Stripe.ChargeRetrieveParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.charges.retrieve(id, params, options)
}

// Invoices
retrieveInvoice(
id: string,
params?: Stripe.InvoiceRetrieveParams,
options?: Stripe.RequestOptions,
) {
return this.stripe.invoices.retrieve(id, params, options)
}
}
Loading
Loading