Skip to content

Commit

Permalink
feat(payments-plugin): Make Stripe plugin channel-aware (#2058)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: The Stripe plugin has been made channel aware. This means your api key and webhook secret are now stored in the database, per channel, instead of environment variables.

To migrate to v2 of the Stripe plugin from @vendure/payments you need to:

Remove the apiKey and webhookSigningSecret from the plugin initialization in vendure-config.ts:
```diff
-StripePlugin.init({
-    apiKey: process.env.YOUR_STRIPE_SECRET_KEY,
-    webhookSigningSecret: process.env.YOUR_STRIPE_WEBHOOK_SIGNING_SECRET,
-    storeCustomersInStripe: true,
-}),
+StripePlugin.init({
+    storeCustomersInStripe: true,
+ }),
```
Start the server and login as administrator.

For each channel that you'd like to use Stripe payments, you need to create a payment method with payment handler Stripe payment and the apiKey and webhookSigningSecret belonging to that channel's Stripe account.
  • Loading branch information
martijnvdbrug authored Apr 21, 2023
1 parent 6ada0b3 commit 3b88702
Show file tree
Hide file tree
Showing 16 changed files with 491 additions and 163 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"hooks": {
"commit-msg": "commitlint -e $HUSKY_GIT_PARAMS",
"post-commit": "git update-index --again",
"pre-commit": "lint-staged",
"pre-commit": "NODE_OPTIONS=\"--max-old-space-size=8096\" lint-staged",
"pre-push": "yarn check-imports && yarn check-angular-versions && yarn build && yarn test && yarn e2e"
}
},
Expand Down
27 changes: 10 additions & 17 deletions packages/job-queue-plugin/src/bullmq/bullmq-job-queue-strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,7 @@ import {
Logger,
PaginatedList,
} from '@vendure/core';
import Bull, {
ConnectionOptions,
JobType,
Processor,
Queue,
Worker,
WorkerOptions,
} from 'bullmq';
import Bull, { ConnectionOptions, JobType, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
import { EventEmitter } from 'events';
import { Cluster, Redis, RedisOptions } from 'ioredis';

Expand Down Expand Up @@ -94,6 +87,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
if (processFn) {
const job = await this.createVendureJob(bullJob);
try {
// eslint-disable-next-line
job.on('progress', _job => bullJob.updateProgress(_job.progress));
const result = await processFn(job);
await bullJob.updateProgress(100);
Expand Down Expand Up @@ -212,6 +206,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
}
}

// TODO V2: actually make it use the olderThan parameter
async removeSettledJobs(queueNames?: string[], olderThan?: Date): Promise<number> {
try {
const jobCounts = await this.queue.getJobCounts('completed', 'failed');
Expand All @@ -224,6 +219,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
}
}

// eslint-disable-next-line @typescript-eslint/require-await
async start<Data extends JobData<Data> = object>(
queueName: string,
process: (job: Job<Data>) => Promise<any>,
Expand All @@ -238,19 +234,19 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
this.worker = new Worker(QUEUE_NAME, this.workerProcessor, options)
.on('error', e => Logger.error(`BullMQ Worker error: ${e.message}`, loggerCtx, e.stack))
.on('closing', e => Logger.verbose(`BullMQ Worker closing: ${e}`, loggerCtx))
.on('closed', () => Logger.verbose(`BullMQ Worker closed`))
.on('closed', () => Logger.verbose('BullMQ Worker closed'))
.on('failed', (job: Bull.Job | undefined, error) => {
Logger.warn(
`Job ${job?.id} [${job?.name}] failed (attempt ${job?.attemptsMade} of ${
job?.opts.attempts ?? 1
})`,
`Job ${job?.id ?? '(unknown id)'} [${job?.name ?? 'unknown name'}] failed (attempt ${
job?.attemptsMade ?? 'unknown'
} of ${job?.opts.attempts ?? 1})`,
);
})
.on('stalled', (jobId: string) => {
Logger.warn(`BullMQ Worker: job ${jobId} stalled`, loggerCtx);
})
.on('completed', (job: Bull.Job) => {
Logger.debug(`Job ${job.id} [${job.name}] completed`);
Logger.debug(`Job ${job?.id ?? 'unknown id'} [${job.name}] completed`);
});
}
}
Expand All @@ -263,10 +259,7 @@ export class BullMQJobQueueStrategy implements InspectableJobQueueStrategy {
if (!this.stopped) {
this.stopped = true;
try {
await Promise.all([
this.queue.disconnect(),
this.worker.disconnect(),
]);
await Promise.all([this.queue.disconnect(), this.worker.disconnect()]);
} catch (e: any) {
Logger.error(e, loggerCtx, e.stack);
}
Expand Down
20 changes: 20 additions & 0 deletions packages/payments-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,23 @@ will create an order, set Mollie as payment method, and create a payment intent
6. Watch the logs for `Mollie payment link` and click the link to finalize the test payment.

You can change the order flow, payment methods and more in the file `e2e/mollie-dev-server`, and restart the devserver.

### Stripe local development

For testing out changes to the Stripe plugin locally, with a real Stripe account, follow the steps below. These steps
will create an order, set Stripe as payment method, and create a payment secret.

1. Get a test api key from your Stripe
dashboard: https://dashboard.stripe.com/test/apikeys
2. Use Ngrok or Localtunnel to make your localhost publicly available and create a webhook as described here: https://www.vendure.io/docs/typescript-api/payments-plugin/stripe-plugin/
3. Create the file `packages/payments-plugin/.env` with these contents:
```sh
STRIPE_APIKEY=sk_test_xxxx
STRIPE_WEBHOOK_SECRET=webhook-secret
STRIPE_PUBLISHABLE_KEY=pk_test_xxxx
```
1. `cd packages/payments-plugin`
2. `yarn dev-server:stripe`
3. Watch the logs for the link or go to `http://localhost:3050/checkout` to test the checkout.

After checkout completion you can see your payment in https://dashboard.stripe.com/test/payments/
14 changes: 7 additions & 7 deletions packages/payments-plugin/e2e/mollie-dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
apiOptions: {
adminApiPlayground: true,
shopApiPlayground: true,
}
},
});
const { server, shopClient, adminClient } = createTestEnvironment(config as any);
await server.init({
Expand Down Expand Up @@ -80,7 +80,7 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
arguments: [
{
name: 'redirectUrl',
value: `${tunnel.url as string}/admin/orders?filter=open&page=1`,
value: `${tunnel.url}/admin/orders?filter=open&page=1`,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ name: 'apiKey', value: process.env.MOLLIE_APIKEY! },
Expand Down Expand Up @@ -109,14 +109,14 @@ import { CREATE_MOLLIE_PAYMENT_INTENT, setShipping } from './payment-helpers';
await setShipping(shopClient);
// Add pre payment to order
const order = await server.app.get(OrderService).findOne(ctx, 1);
// tslint:disable-next-line:no-non-null-assertion
await server.app.get(PaymentService).createManualPayment(ctx, order!, 10000 ,{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await server.app.get(PaymentService).createManualPayment(ctx, order!, 10000, {
method: 'Manual',
// tslint:disable-next-line:no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
orderId: order!.id,
metadata: {
bogus: 'test'
}
bogus: 'test',
},
});
const { createMolliePaymentIntent } = await shopClient.query(CREATE_MOLLIE_PAYMENT_INTENT, {
input: {
Expand Down
25 changes: 16 additions & 9 deletions packages/payments-plugin/e2e/payment-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { SimpleGraphQLClient, TestServer } from '@vendure/testing';
import gql from 'graphql-tag';

import { REFUND_ORDER } from './graphql/admin-queries';
import { RefundFragment, RefundOrder } from './graphql/generated-admin-types';
import { RefundFragment, RefundOrderMutation, RefundOrderMutationVariables } from './graphql/generated-admin-types';
import {
GetShippingMethods,
SetShippingMethod,
GetShippingMethodsQuery,
SetShippingMethodMutation,
SetShippingMethodMutationVariables,
TestOrderFragmentFragment,
TransitionToState,
TransitionToStateMutation,
TransitionToStateMutationVariables,
} from './graphql/generated-shop-types';
import {
GET_ELIGIBLE_SHIPPING_METHODS,
Expand All @@ -28,19 +30,19 @@ export async function setShipping(shopClient: SimpleGraphQLClient): Promise<void
countryCode: 'AT',
},
});
const { eligibleShippingMethods } = await shopClient.query<GetShippingMethods.Query>(
const { eligibleShippingMethods } = await shopClient.query<GetShippingMethodsQuery>(
GET_ELIGIBLE_SHIPPING_METHODS,
);
await shopClient.query<SetShippingMethod.Mutation, SetShippingMethod.Variables>(SET_SHIPPING_METHOD, {
await shopClient.query<SetShippingMethodMutation, SetShippingMethodMutationVariables>(SET_SHIPPING_METHOD, {
id: eligibleShippingMethods[1].id,
});
}

export async function proceedToArrangingPayment(shopClient: SimpleGraphQLClient): Promise<ID> {
await setShipping(shopClient);
const { transitionOrderToState } = await shopClient.query<
TransitionToState.Mutation,
TransitionToState.Variables
TransitionToStateMutation,
TransitionToStateMutationVariables
>(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return (transitionOrderToState as TestOrderFragmentFragment)!.id;
Expand All @@ -53,7 +55,7 @@ export async function refundOrderLine(
paymentId: string,
adjustment: number,
): Promise<RefundFragment> {
const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(
const { refundOrder } = await adminClient.query<RefundOrderMutation, RefundOrderMutationVariables>(
REFUND_ORDER,
{
input: {
Expand Down Expand Up @@ -102,6 +104,11 @@ export const CREATE_MOLLIE_PAYMENT_INTENT = gql`
}
`;

export const CREATE_STRIPE_PAYMENT_INTENT = gql`
mutation createStripePaymentIntent{
createStripePaymentIntent
}`;

export const GET_MOLLIE_PAYMENT_METHODS = gql`
query molliePaymentMethods($input: MolliePaymentMethodsInput!) {
molliePaymentMethods(input: $input) {
Expand Down
88 changes: 88 additions & 0 deletions packages/payments-plugin/e2e/stripe-checkout-test.plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/* eslint-disable */
import { Controller, Res, Get } from '@nestjs/common';
import { PluginCommonModule, VendurePlugin } from '@vendure/core';
import { Response } from 'express';

import { clientSecret } from './stripe-dev-server';

/**
* This test controller returns the Stripe intent checkout page
* with the client secret generated by the dev-server
*/
@Controller()
export class StripeTestCheckoutController {
@Get('checkout')
async webhook(@Res() res: Response): Promise<void> {
res.send(`
<head>
<title>Checkout</title>
<script src="https://js.stripe.com/v3/"></script>
</head>
<html>
<form id="payment-form">
<div id="payment-element">
<!-- Elements will create form elements here -->
</div>
<button id="submit">Submit</button>
<div id="error-message">
<!-- Display error message to your customers here -->
</div>
</form>
<script>
// Set your publishable key: remember to change this to your live publishable key in production
// See your keys here: https://dashboard.stripe.com/apikeys
const stripe = Stripe('${process.env.STRIPE_PUBLISHABLE_KEY}');
const options = {
clientSecret: '${clientSecret}',
// Fully customizable with appearance API.
appearance: {/*...*/},
};
// Set up Stripe.js and Elements to use in checkout form, passing the client secret obtained in step 3
const elements = stripe.elements(options);
// Create and mount the Payment Element
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (event) => {
event.preventDefault();
// const {error} = await stripe.confirmSetup({
const {error} = await stripe.confirmPayment({
//\`Elements\` instance that was used to create the Payment Element
elements,
confirmParams: {
return_url: 'http://localhost:3050/checkout?success=true',
}
});
if (error) {
// This point will only be reached if there is an immediate error when
// confirming the payment. Show error to your customer (for example, payment
// details incomplete)
const messageContainer = document.querySelector('#error-message');
messageContainer.textContent = error.message;
} else {
// Your customer will be redirected to your \`return_url\`. For some payment
// methods like iDEAL, your customer will be redirected to an intermediate
// site first to authorize the payment, then redirected to the \`return_url\`.
}
});
</script>
</html>
`);
}
}

/**
* Test plugin for serving the Stripe intent checkout page
*/
@VendurePlugin({
imports: [PluginCommonModule],
controllers: [StripeTestCheckoutController],
})
export class StripeCheckoutTestPlugin {}
Loading

0 comments on commit 3b88702

Please sign in to comment.