From 9287b4da1abafc5291c5f0aaa025fd6e44fbad59 Mon Sep 17 00:00:00 2001 From: Tony Grosinger Date: Mon, 25 Dec 2023 19:04:15 -0800 Subject: [PATCH] Use Stripe Address and email admin on membership creation --- package-lock.json | 142 +++++++++++++++++++++++++++- package.json | 1 + src/app/api/stripe_intent/route.ts | 6 +- src/app/api/stripe_webhook/route.ts | 29 ++++++ src/app/club/payment.tsx | 110 +++++++++++---------- src/app/thank-you/page.tsx | 12 ++- src/lib/email.ts | 38 ++++++++ 7 files changed, 273 insertions(+), 65 deletions(-) create mode 100644 src/app/api/stripe_webhook/route.ts create mode 100644 src/lib/email.ts diff --git a/package-lock.json b/package-lock.json index 8059866..6701984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "feed": "^4.2.2", "next": "13.4.16", "next-themes": "^0.2.1", + "postmark": "4.0.2", "react": "18.2.0", "react-dom": "18.2.0", "remark-gfm": "^3.0.1", @@ -1300,6 +1301,11 @@ "astring": "bin/astring" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/autoprefixer": { "version": "10.4.14", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", @@ -1353,6 +1359,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -2085,6 +2101,17 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -2351,6 +2378,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3446,6 +3481,25 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -3455,6 +3509,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", @@ -6706,7 +6773,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "peer": true, "engines": { "node": ">= 0.6" } @@ -6715,7 +6781,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -7721,6 +7786,14 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/postmark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-4.0.2.tgz", + "integrity": "sha512-2zlCv+KVVQ0KoamXZHE7d+gXzLlr8tPE+PxQmtUaIZhbHzZAq4D6yH2b+ykhA8wYCc5ISodcx8U1aNLenXBs9g==", + "dependencies": { + "axios": "^1.6.2" + } + }, "node_modules/prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -7914,6 +7987,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", @@ -11351,6 +11429,11 @@ "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.6.tgz", "integrity": "sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "autoprefixer": { "version": "10.4.14", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.14.tgz", @@ -11376,6 +11459,16 @@ "integrity": "sha512-zIURGIS1E1Q4pcrMjp+nnEh+16G56eG/MUllJH8yEvw7asDo7Ac9uhC9KIH5jzpITueEZolfYglnCGIuSBz39g==", "dev": true }, + "axios": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", + "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -11883,6 +11976,14 @@ "simple-swizzle": "^0.2.2" } }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -12067,6 +12168,11 @@ "object-keys": "^1.1.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -12896,6 +13002,11 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -12905,6 +13016,16 @@ "is-callable": "^1.1.3" } }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "fraction.js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", @@ -15147,14 +15268,12 @@ "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "peer": true + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" }, "mime-types": { "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "peer": true, "requires": { "mime-db": "1.52.0" } @@ -15835,6 +15954,14 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "postmark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postmark/-/postmark-4.0.2.tgz", + "integrity": "sha512-2zlCv+KVVQ0KoamXZHE7d+gXzLlr8tPE+PxQmtUaIZhbHzZAq4D6yH2b+ykhA8wYCc5ISodcx8U1aNLenXBs9g==", + "requires": { + "axios": "^1.6.2" + } + }, "prebuild-install": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", @@ -15934,6 +16061,11 @@ "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.2.0.tgz", "integrity": "sha512-kma4U7AFCTwpqq5twzC1YVIDXSqg6qQK6JN0smOw8fgRy1OkMi0CYSzFmsy6dnqSenamAtj0CyXMUJ1Mf6oROg==" }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "pseudomap": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", diff --git a/package.json b/package.json index 82352c3..1e4ce45 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "feed": "^4.2.2", "next": "13.4.16", "next-themes": "^0.2.1", + "postmark": "4.0.2", "react": "18.2.0", "react-dom": "18.2.0", "remark-gfm": "^3.0.1", diff --git a/src/app/api/stripe_intent/route.ts b/src/app/api/stripe_intent/route.ts index cdb8c66..aad1d6a 100644 --- a/src/app/api/stripe_intent/route.ts +++ b/src/app/api/stripe_intent/route.ts @@ -4,7 +4,7 @@ import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || ''); export async function POST(request: NextRequest): Promise { - const { amount, payment_intent_id } = await request.json(); + const { amount, payment_intent_id, metadata } = await request.json(); if (payment_intent_id) { try { @@ -18,6 +18,7 @@ export async function POST(request: NextRequest): Promise { payment_intent_id, { amount: amount, + metadata: metadata || {}, }, ); @@ -38,13 +39,14 @@ export async function POST(request: NextRequest): Promise { try { // Create PaymentIntent - const params = { + const params: Stripe.PaymentIntentCreateParams = { amount: amount, currency: 'usd', payment_method_types: ['card'], automatic_payment_methods: { enabled: false, }, + metadata: metadata || {}, }; const payment_intent = await stripe.paymentIntents.create(params); //Return the payment_intent object diff --git a/src/app/api/stripe_webhook/route.ts b/src/app/api/stripe_webhook/route.ts new file mode 100644 index 0000000..45d6ad1 --- /dev/null +++ b/src/app/api/stripe_webhook/route.ts @@ -0,0 +1,29 @@ +import { emailNotification } from '@/lib/email'; +import { NextRequest } from 'next/server'; + +export async function POST(request: NextRequest): Promise { + const body = await request.json(); + + if (body.type === 'charge.succeeded') { + const data = body.data.object; + const billing = data.billing_details; + const cityStateZip = `${billing.address.city}, ${billing.address.state} ${billing.address.postal_code}`; + const address = billing.address.line2 + ? `${billing.address.line1}\n${cityStateZip}` + : `${billing.address.line1}\n${billing.address.line2}\n${cityStateZip}`; + + emailNotification({ + name: billing.name, + type: data.metadata.type, + amount: data.amount, + email: billing.email, + phone: billing.phone, + address, + }); + } else { + console.log('Another type of event'); + console.log(body); + } + + return new Response(null, { status: 200 }); +} diff --git a/src/app/club/payment.tsx b/src/app/club/payment.tsx index 7d0a23f..9555a26 100644 --- a/src/app/club/payment.tsx +++ b/src/app/club/payment.tsx @@ -1,8 +1,8 @@ "use client" import React, { useEffect, useState, FormEvent } from 'react'; -import { Elements } from '@stripe/react-stripe-js'; -import { Appearance, StripeElementsOptions, loadStripe } from '@stripe/stripe-js'; +import { AddressElement, Elements } from '@stripe/react-stripe-js'; +import { Appearance, StripeAddressElementOptions, StripeElementsOptions, loadStripe } from '@stripe/stripe-js'; import { PaymentElement, useStripe, @@ -12,6 +12,25 @@ import { RadioGroup } from '@headlessui/react' const stripe = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || ''); +const addressOptions: StripeAddressElementOptions = { + mode: 'billing', + allowedCountries: ['US'], + fields: { + phone: 'always', + }, + validation: { + phone: { + required: 'always' + } + }, + defaultValues: { + address: { + state: 'WA', + country: 'US', + }, + } +}; + const membershipLevels = [ { id: 1, title: 'Individual', description: 'One vote', price: 20 }, { id: 2, title: 'Household', description: 'Two votes', price: 30 }, @@ -40,9 +59,7 @@ function CheckoutForm({ const [selectedAdditionalDonation, setSelectedAdditionalDonation] = useState(null); const [customAmount, setCustomAmount] = useState(''); const [email, setEmail] = useState(''); - const [phone, setPhone] = useState(''); - const [street, setStreet] = useState(''); - const [zip, setZip] = useState(''); + const [offsetFees, setOffsetFees] = useState(true); const [totalAmount, setTotalAmount] = useState(300); const [message, setMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -94,6 +111,10 @@ function CheckoutForm({ } } + if (offsetFees) { + subtotal = Math.ceil(subtotal * 1.03) + } + setTotalAmount(subtotal); fetch('api/stripe_intent', { @@ -102,9 +123,12 @@ function CheckoutForm({ body: JSON.stringify({ amount: subtotal * 100, payment_intent_id: paymentIntentID, + metadata: { + 'type': selectedMembershipLevel.title, + }, }), }); - }, [paymentIntentID, selectedMembershipLevel, selectedAdditionalDonation, customAmount]) + }, [paymentIntentID, selectedMembershipLevel, selectedAdditionalDonation, customAmount, offsetFees, email]) const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -123,7 +147,8 @@ function CheckoutForm({ receipt_email: email, payment_method_data: { billing_details: { - name: 'Billing user', + // Other details are filled automatically by address form. + email, }, }, }, @@ -270,54 +295,7 @@ function CheckoutForm({ placeholder="you@example.com" /> -
- - setPhone(e.target.value)} - className="block w-full border-0 p-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm sm:leading-6" - placeholder="360-376-XXXX" - /> -
-
-
-
-
- - setStreet(e.target.value)} - className="relative block w-full rounded-none rounded-l-md border-0 bg-transparent py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - placeholder="Street" - /> -
-
- - setZip(e.target.value)} - className="relative block w-full rounded-none rounded-r-md border-0 bg-transparent py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:z-10 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - placeholder="98XXX" - /> -
-
-
-
+
@@ -334,6 +312,24 @@ function CheckoutForm({ {/* TODO: Automatically renew toggle? */} +
+
+ setOffsetFees(e.target.checked)} + className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" + /> +
+
+ +
+
+