diff --git a/src/lib/payment-processing/checkoutSession.test.ts b/src/lib/payment-processing/checkoutSession.test.ts index cdd55f8..a046cb1 100644 --- a/src/lib/payment-processing/checkoutSession.test.ts +++ b/src/lib/payment-processing/checkoutSession.test.ts @@ -72,8 +72,8 @@ describe('Stripe', () => { } ], mode: 'payment', - success_url: 'http://localhost:5173/success/{CHECKOUT_SESSION_ID}/', - cancel_url: 'http://localhost:5173/cart/?session_id={CHECKOUT_SESSION_ID}', + success_url: 'http://localhost:5173/success/{CHECKOUT_SESSION_ID}/?provider=stripe', + cancel_url: 'http://localhost:5173/cart/?session_id={CHECKOUT_SESSION_ID}&provider=stripe', allow_promotion_codes: true, shipping_options: [ { @@ -152,8 +152,8 @@ describe('Radom', () => { ] } }, - successUrl: 'http://localhost:5173/success/{CHECKOUT_SESSION_ID}/', - cancelUrl: 'http://localhost:5173/cart/?session_id={CHECKOUT_SESSION_ID}', + successUrl: 'http://localhost:5173/success/{CHECKOUT_SESSION_ID}/?provider=radom', + cancelUrl: 'http://localhost:5173/cart/?session_id={CHECKOUT_SESSION_ID}&provider=radom', chargeCustomerNetworkFee: true, customizations: { allowDiscountCodes: true } }; diff --git a/src/lib/payment-processing/checkoutSession.ts b/src/lib/payment-processing/checkoutSession.ts index cebde61..fe7cd7e 100644 --- a/src/lib/payment-processing/checkoutSession.ts +++ b/src/lib/payment-processing/checkoutSession.ts @@ -229,7 +229,7 @@ export async function formatCheckoutSessionParams( const encryptedShippingData = await encryptShippingData(recipientInformation); - return providerAdapter(hydratedItems, encryptedShippingData, shippingRates); + return providerAdapter(hydratedItems, recipientInformation, encryptedShippingData, shippingRates); } // TODO: write tests for initCheckoutSession diff --git a/src/lib/payment-processing/providers/radom/adapter.ts b/src/lib/payment-processing/providers/radom/adapter.ts index db10c2e..57623d0 100644 --- a/src/lib/payment-processing/providers/radom/adapter.ts +++ b/src/lib/payment-processing/providers/radom/adapter.ts @@ -1,17 +1,28 @@ import { env } from '$env/dynamic/private'; import { ENVIRONMENT } from '$env/static/private'; -import { CANCELED_SESSION_QUERY_PARAM, PROVIDER_QUERY_PARAM } from '$lib/payment-processing/constants'; +import { + CANCELED_SESSION_QUERY_PARAM, + PROVIDER_QUERY_PARAM +} from '$lib/payment-processing/constants'; import type { ProviderParamsAdapter, ShippingRate } from '$lib/payment-processing/types'; import { PROVIDER_NAME } from '.'; import { mainnetTokens, testnetTokens } from './tokens'; import type { Currency, Radom } from './types'; +export const metadataKeys = { + ENCRYPTED_SHIPPING_DATA: 'encryptedShippingData', + SHIPPING_DATA_ENCRYPTION_KEY_ID: 'shippingDataEncryptionKeyId', + SHIPPING_RATE_ID: 'shippingRateId', + ITEM: 'item' +}; + export const radomAdapter: ProviderParamsAdapter = ( items, + recipient, encryptedShippingAddress, shippingRates ) => { - const lineItems: Array = items.map((item) => { + const lineItems: Array = items.map((item) => { return { itemData: { name: item.details.name, @@ -32,27 +43,38 @@ export const radomAdapter: ProviderParamsAdapter ({ + key: metadataKeys.ITEM, + value: JSON.stringify({ + id: i.printfulVariantId, + quantity: i.quantity + }) + })) ], chargeCustomerNetworkFee: true, customizations: { diff --git a/src/lib/payment-processing/providers/radom/index.ts b/src/lib/payment-processing/providers/radom/index.ts index f96cf39..e53eec6 100644 --- a/src/lib/payment-processing/providers/radom/index.ts +++ b/src/lib/payment-processing/providers/radom/index.ts @@ -21,7 +21,8 @@ export async function radomApi(resourcePath: string, options?: RequestI } }); - const responseBody = await response.json(); + const responseBody = + parseInt(response.headers.get('content-length')) > 0 ? await response.json() : ''; if (response.status !== 200) { error(400, responseBody); diff --git a/src/lib/payment-processing/providers/radom/types.ts b/src/lib/payment-processing/providers/radom/types.ts index 1ff7802..e52e494 100644 --- a/src/lib/payment-processing/providers/radom/types.ts +++ b/src/lib/payment-processing/providers/radom/types.ts @@ -14,9 +14,11 @@ export namespace Radom { }; export namespace Checkout { + export type SessionStatus = 'pending' | 'success' | 'cancelled' | 'expired' | 'refunded'; + export type Session = { id: string; - sessionStatus: Status; + sessionStatus: SessionStatus; organizationId: string; products?: Product[]; items?: ItemData[]; @@ -60,8 +62,6 @@ export namespace Radom { }; } - type Status = 'pending' | 'success' | 'cancelled' | 'expired' | 'refunded'; - type Product = { id: string; organizationId: string; diff --git a/src/lib/payment-processing/providers/stripe/adapter.ts b/src/lib/payment-processing/providers/stripe/adapter.ts index 906183b..d9b37b1 100644 --- a/src/lib/payment-processing/providers/stripe/adapter.ts +++ b/src/lib/payment-processing/providers/stripe/adapter.ts @@ -6,6 +6,7 @@ import type { ProviderParamsAdapter } from '../../types'; export const stripeAdapter: ProviderParamsAdapter = ( items, + recipient, encryptedShippingAddress, shippingRates ) => { @@ -74,6 +75,7 @@ export const stripeAdapter: ProviderParamsAdapter = ( items: HydratedCartItem[], + recipient: App.Recipient, encryptedShippingData: EncryptedShippingAddress, shippingRates: ShippingRate[] ) => SessionCreateParams; diff --git a/src/params/checkoutSessionId.js b/src/params/checkoutSessionId.js index a823b67..35c9daf 100644 --- a/src/params/checkoutSessionId.js +++ b/src/params/checkoutSessionId.js @@ -1,4 +1,4 @@ /** @type {import("@sveltejs/kit").ParamMatcher} */ export function match(param) { - return /^cs_[a-zA-Z0-9_-]+$/.test(param); + return /^(?:cs_)?[a-zA-Z0-9_-]+$/.test(param); } diff --git a/src/routes/cart/+page.server.ts b/src/routes/cart/+page.server.ts index 733f0b4..11afe8d 100644 --- a/src/routes/cart/+page.server.ts +++ b/src/routes/cart/+page.server.ts @@ -9,7 +9,8 @@ import { createRadomCheckoutSession, PROVIDER_NAME as RADOM_PROVIDER_NAME, radomAdapter, - radomApi + radomApi, + type Radom } from '$lib/payment-processing/providers/radom'; import { stripe, @@ -21,7 +22,6 @@ import { blockedCountryCodes } from '$lib/utils'; import type { CountryCallingCode, CountryCode } from 'libphonenumber-js'; import { getCountryCallingCode, isSupportedCountry } from 'libphonenumber-js/max'; import type { Actions, PageServerLoad } from './$types'; -import { redirect } from '@sveltejs/kit'; export type CartRequestBody = { items: Array<{ @@ -66,20 +66,35 @@ export const load: PageServerLoad = async ({ url }) => { try { if (provider === STRIPE_PROVIDER_NAME) { const session = await stripe.checkout.sessions.retrieve(canceledSession); + if (session.status === 'open') { + await stripe.checkout.sessions.expire(canceledSession); + } if (session.metadata?.keyId) { await sdk.DeleteShippingDataKey({ id: session.metadata?.keyId }); } } else if (provider === RADOM_PROVIDER_NAME) { - const session = await radomApi<{ metadata: [{ key: string; value: string }] }>( - `/checkout_session/${canceledSession}` - ); + const session = await radomApi<{ + metadata: [{ key: string; value: string }]; + sessionStatus: Radom.Checkout.SessionStatus; + }>(`/checkout_session/${canceledSession}`); + if (session.sessionStatus === "pending") { + await radomApi(`/checkout_session/${canceledSession}/cancel`, { + method: 'POST' + }); + } const keyId = session.metadata.find((v) => v.key === 'shippingDataEncryptionKeyId')?.value; if (keyId) { await sdk.DeleteShippingDataKey({ id: keyId }); } } } catch (e: any) { - console.log(`Could not delete shipping data key due to error: ${e.message}`); + if (e?.message?.includes('ShippingDataKey')) { + console.error( + `Could not delete shipping data key due to error: ${e?.response?.errors[0]?.message}` + ); + } else { + console.error(e); + } } } diff --git a/src/routes/cart/+page.svelte b/src/routes/cart/+page.svelte index db7f466..3300c66 100644 --- a/src/routes/cart/+page.svelte +++ b/src/routes/cart/+page.svelte @@ -186,16 +186,27 @@ {#if showShippingAddress}

Shipping address

-
- +
+ +
+ +
+ +
@@ -408,6 +419,7 @@ } &.errors input[type='text'], + &.errors input[type='email'], &.errors select { --bg: theme('colors.systemfeedback.error-background'); } @@ -470,6 +482,7 @@ } input[type='text'], + input[type='email'], select { @apply text-default-regular; diff --git a/src/routes/success/[sessionId=checkoutSessionId]/+page.server.ts b/src/routes/success/[sessionId=checkoutSessionId]/+page.server.ts index ba687a0..f1d84ab 100644 --- a/src/routes/success/[sessionId=checkoutSessionId]/+page.server.ts +++ b/src/routes/success/[sessionId=checkoutSessionId]/+page.server.ts @@ -1,10 +1,18 @@ -import { redirect } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; -import { stripe } from '$lib/payment-processing/providers/stripe'; -import type Stripe from 'stripe'; -import type { PageServerLoad } from './$types'; import { sdk } from '$lib/graphql/sdk'; +import { + PROVIDER_NAME as RADOM_PROVIDER_NAME, + radomApi, + type Radom +} from '$lib/payment-processing/providers/radom'; +import { + stripe, + PROVIDER_NAME as STRIPE_PROVIDER_NAME +} from '$lib/payment-processing/providers/stripe'; import * as Sentry from '@sentry/node'; +import { redirect } from '@sveltejs/kit'; +import type Stripe from 'stripe'; +import type { PageServerLoad } from './$types'; // TODO: Implement cancel logic // Add param to distinguish between Stripe and Radom @@ -20,47 +28,70 @@ Sentry.init({ } }); -export const load: PageServerLoad = async function load({ params }) { +export const load: PageServerLoad = async function load({ params, url }) { + const provider = url.searchParams.get('provider'); try { - const session = await stripe.checkout.sessions.retrieve(params.sessionId, { - expand: ['line_items.data.price.product'] - }); + let purchasedVariants: { quantity: number; id: string }[] = []; - /** - * Determine if webhook associated with checkout session was successful - */ - const events = await stripe.events.list({ - type: 'checkout.session.completed', - created: { - gte: session.created - } - }); + if (!provider) { + throw new Error('No provider was specified.'); + } else if (provider === STRIPE_PROVIDER_NAME) { + const session = await stripe.checkout.sessions.retrieve(params.sessionId, { + expand: ['line_items.data.price.product'] + }); + + /** + * Determine if webhook associated with checkout session was successful + */ + const events = await stripe.events.list({ + type: 'checkout.session.completed', + created: { + gte: session.created + } + }); - const sessionEvents = events.data.filter((e) => { - const resource = e.data.object as Stripe.Checkout.Session; - return resource?.id === session.id; - }); + const sessionEvents = events.data.filter((e) => { + const resource = e.data.object as Stripe.Checkout.Session; + return resource?.id === session.id; + }); - // If most recent event (index 0) has pending_webhooks, trigger alert. - if (sessionEvents[0]?.pending_webhooks > 0) { - console.log('Pending webhooks: ', sessionEvents[0].pending_webhooks); - Sentry.captureMessage( - `Customer order not submitted to Printful ${session.payment_intent}`, - 'error' + // If most recent event (index 0) has pending_webhooks, trigger alert. + if (sessionEvents[0]?.pending_webhooks > 0) { + console.log('Pending webhooks: ', sessionEvents[0].pending_webhooks); + Sentry.captureMessage( + `Customer order not submitted to Printful ${session.payment_intent}`, + 'error' + ); + } + + purchasedVariants = + session.line_items?.data?.map((li) => { + const product = li.price?.product as Stripe.Product; + const variantId = product.metadata.printfulVariantId; + return { + quantity: li.quantity, + id: variantId + }; + }) ?? []; + } else if (provider === RADOM_PROVIDER_NAME) { + const session: Radom.Checkout.Session = await radomApi( + `/checkout_session/${params.sessionId}` ); + + purchasedVariants = session.metadata + .filter((s) => s.key === 'item') + .map((item) => JSON.parse(item.value)); } const items = await Promise.all( - session.line_items?.data?.map(async (li) => { - const product = li.price?.product as Stripe.Product; - const variantId = product.metadata.printfulVariantId; - const { variants } = await sdk.Variant({ printfulId: variantId }); + purchasedVariants.map(async (variant) => { + const { variants } = await sdk.Variant({ printfulId: variant.id }); return { - quantity: li.quantity, + quantity: variant.quantity, product: variants?.at(0) }; - }) ?? [] + }) ); return { diff --git a/src/routes/webhooks/radom/+server.ts b/src/routes/webhooks/radom/+server.ts index 6b2ce27..b19d326 100644 --- a/src/routes/webhooks/radom/+server.ts +++ b/src/routes/webhooks/radom/+server.ts @@ -1,14 +1,12 @@ -import type { RequestHandler } from './$types'; -import type { Stripe } from 'stripe'; import { env } from '$env/dynamic/private'; -import { stripe } from '$lib/payment-processing/providers/stripe'; -import * as printfulApi from '$lib/printful-api'; -import { decrypt, blockedCountryCodes, ValidationError } from '$lib/utils'; import { sdk } from '$lib/graphql/sdk'; +import * as printfulApi from '$lib/printful-api'; +import { blockedCountryCodes, decrypt, ValidationError } from '$lib/utils'; +import type { RequestHandler } from './$types'; -import * as Sentry from '@sentry/node'; -import { radomApi, type Radom } from '$lib/payment-processing/providers/radom'; import { RADOM_WEBHOOK_VERIFICATION_KEY } from '$env/static/private'; +import { metadataKeys, radomApi, type Radom } from '$lib/payment-processing/providers/radom'; +import * as Sentry from '@sentry/node'; Sentry.init({ dsn: env.SENTRY_DSN, @@ -39,7 +37,9 @@ export const POST: RequestHandler = async ({ request }) => { fulfillOrder(session); } catch (e: any) { if (e.response?.errors[0]?.extensions?.prisma?.code === 'P2002') { - console.log(`Order for ${session.payment.managed.paymentEventId} has already been processed.`); + console.log( + `Order for ${session.payment.managed.paymentEventId} has already been processed.` + ); } } } @@ -48,31 +48,16 @@ export const POST: RequestHandler = async ({ request }) => { }; async function fulfillOrder(session: Radom.Checkout.Session): Promise { - return; try { - const sessionDetails = await stripe.checkout.sessions.retrieve(session.id, { - expand: ['line_items.data.price.product', 'shipping_cost.shipping_rate'] - }); + const shippingRate = session.metadata.find((d) => d.key === metadataKeys.SHIPPING_RATE_ID); - const shippingRate = sessionDetails.shipping_cost?.shipping_rate as Stripe.ShippingRate; - const line_items = sessionDetails.line_items?.data; - const { customer_details, metadata } = sessionDetails; - - // TODO: is this actually going to work? I think we need to decode the shipping address first... - if (blockedCountryCodes.includes(metadata?.country_code as string)) { - throw new ValidationError('Invalid recipient region.'); - } - - const items = line_items?.map((li): App.OrderItem => { - const product = li.price?.product as Stripe.Product; - return { - quantity: li.quantity ?? 1, - sync_variant_id: parseInt(product.metadata.printfulVariantId) - }; - }); - - const encryptedShippingData = metadata?.shippingData; - const { shippingDataKey } = await sdk.ShippingDataKey({ id: metadata?.keyId }); + const encryptedShippingData = session.metadata.find( + (d) => d.key === metadataKeys.ENCRYPTED_SHIPPING_DATA + ).value; + const shippingDataEncryptionKeyId = session.metadata.find( + (d) => d.key === metadataKeys.SHIPPING_DATA_ENCRYPTION_KEY_ID + ).value; + const { shippingDataKey } = await sdk.ShippingDataKey({ id: shippingDataEncryptionKeyId }); let shippingData: App.Recipient; if (encryptedShippingData && shippingDataKey && shippingDataKey.key) { shippingData = decrypt(encryptedShippingData, shippingDataKey.key); @@ -80,10 +65,24 @@ async function fulfillOrder(session: Radom.Checkout.Session): Promise { throw new Error('Could not find encrypted shippingData or shippingDataKey.'); } + if (blockedCountryCodes.includes(shippingData.country_code)) { + throw new ValidationError('Invalid recipient region.'); + } + + const items = session.metadata + .filter((d) => d.key === metadataKeys.ITEM) + .map((li): App.OrderItem => { + const { id, quantity } = JSON.parse(li.value); + return { + quantity: quantity ?? 1, + sync_variant_id: parseInt(id) + }; + }); + const newOrder = { recipient: { - name: shippingData?.name || customer_details?.name, - email: customer_details?.email, + name: shippingData?.name, + email: shippingData?.email, address1: shippingData?.address1, address2: shippingData?.address2, city: shippingData?.city, @@ -92,8 +91,8 @@ async function fulfillOrder(session: Radom.Checkout.Session): Promise { country_code: shippingData?.country_code, phone: shippingData?.phone }, - shipping: shippingRate?.metadata?.printful_shipping_rate_id, - external_id: sessionDetails.payment_intent as string, + shipping: shippingRate.value, + external_id: session.payment.managed.paymentEventId.replaceAll('-', ''), // External ID can only be 32 characters items }; @@ -101,11 +100,11 @@ async function fulfillOrder(session: Radom.Checkout.Session): Promise { const isProduction = env.BASE_URL.replace(/\/$/, '').endsWith('brave.com'); const shouldBeDraft = !isProduction; await printfulApi.createOrder(newOrder, { draft: shouldBeDraft }); - await sdk.DeleteShippingDataKey({ id: metadata?.keyId }); + await sdk.DeleteShippingDataKey({ id: shippingDataEncryptionKeyId }); } catch (e: any) { console.log(e); Sentry.captureMessage( - `Customer order not submitted to Printful ${session.payment_intent}`, + `Customer order not submitted to Printful ${session.payment.managed.paymentEventId}`, 'error' ); } diff --git a/src/routes/webhooks/stripe/+server.ts b/src/routes/webhooks/stripe/+server.ts index 80a52dd..c18b499 100644 --- a/src/routes/webhooks/stripe/+server.ts +++ b/src/routes/webhooks/stripe/+server.ts @@ -56,7 +56,16 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { const line_items = sessionDetails.line_items?.data; const { customer_details, metadata } = sessionDetails; - if (blockedCountryCodes.includes(metadata?.country_code as string)) { + const encryptedShippingData = metadata?.shippingData; + const { shippingDataKey } = await sdk.ShippingDataKey({ id: metadata?.keyId }); + let shippingData: App.Recipient; + if (encryptedShippingData && shippingDataKey && shippingDataKey.key) { + shippingData = decrypt(encryptedShippingData, shippingDataKey.key); + } else { + throw new Error('Could not find encrypted shippingData or shippingDataKey.'); + } + + if (blockedCountryCodes.includes(shippingData.country_code)) { throw new ValidationError('Invalid recipient region.'); } @@ -68,15 +77,6 @@ async function fulfillOrder(session: Stripe.Checkout.Session): Promise { }; }); - const encryptedShippingData = metadata?.shippingData; - const { shippingDataKey } = await sdk.ShippingDataKey({ id: metadata?.keyId }); - let shippingData: App.Recipient; - if (encryptedShippingData && shippingDataKey && shippingDataKey.key) { - shippingData = decrypt(encryptedShippingData, shippingDataKey.key); - } else { - throw new Error('Could not find encrypted shippingData or shippingDataKey.'); - } - const newOrder = { recipient: { name: shippingData?.name || customer_details?.name,