Skip to content

Commit

Permalink
feat: printify order integration
Browse files Browse the repository at this point in the history
  • Loading branch information
spencerlepine committed Oct 24, 2024
1 parent bc9babe commit 6549e7d
Show file tree
Hide file tree
Showing 13 changed files with 200 additions and 68 deletions.
8 changes: 7 additions & 1 deletion .env.template
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
NEXT_PUBLIC_URL="http://localhost:3000"

# Stripe - https://dashboard.stripe.com/apikeys
NEXT_PUBLIC_STRIPE_KEY="pk_asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
STRIPE_SECRET_KEY="sk_asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
STRIPE_WEBHOOK_SECRET="whsec_asdfasdfasdfasdfasdfa"
STRIPE_WEBHOOK_SECRET="whsec_asdfasdfasdfasdfasdfa"

# Printify - https://printify.com/app/account/api
PRINTIFY_API_TOKEN="asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
PRINTIFY_SHOP_ID="1234567"
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,15 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.0.3] - 2024-10-14
## [0.0.5] - 2024-10-24

- Printify order integration (mvp, bare bones)

## [0.0.4] - 2024-10-23

- Stripe checkout functionality (w/ async webhook)

## [0.0.3] - 2024-10-19

- Client-side cart item storage
- Catalog filters/pagination
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"dependencies": {
"fuse.js": "^7.0.0",
"next": "14.2.11",
"printify-sdk-js": "^1.0.1",
"react": "^18",
"react-dom": "^18",
"sharp": "^0.33.5",
Expand Down
4 changes: 3 additions & 1 deletion src/app/(orders)/cart/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export default function CartPage() {
const cartItems = Object.values(cartDetails ?? {});

async function handleCheckoutClick() {
// TODO_CHECKOUT - debounce click
// TODO_CHECKOUT - error message popup
if (!cartCount || cartCount === 0) {
return;
}
Expand All @@ -26,7 +28,7 @@ export default function CartPage() {
if (!checkoutUrl) return alert('Unable to checkout at this time. Please try again later.');
router.push(checkoutUrl);
} catch (error) {
console.error(error);
console.error('Checkout request threw an error', error);
}
}

Expand Down
14 changes: 10 additions & 4 deletions src/app/api/v1/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createDraftOrder, formatCartItemsForPrintify } from '@/lib/printify';
import { createCheckoutSession, formatCartItemsForStripe } from '@/lib/stripe';
import validateCartItems from '@/utils/validateCartItems';
import { NextRequest, NextResponse } from 'next/server';
Expand Down Expand Up @@ -74,12 +75,17 @@ export const POST = async (request: NextRequest) => {

const cartItems = validateCartItems(clientCartItems);
if (!cartItems) {
console.error('[Checkout] invalid client cart', clientCartItems);
console.error('[Checkout] cart items are invalid', clientCartItems);
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}

const printifyLineItems = formatCartItemsForPrintify(cartItems);
const { printifyOrderId } = await createDraftOrder(printifyLineItems);
if (!printifyOrderId) {
console.error('[Printify] unable to submit a Printify order');
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}

// TODO_PRINTIFY
const printifyOrderId = 'asdf1234';
const stripeLineItems = formatCartItemsForStripe(cartItems);
const session = await createCheckoutSession(stripeLineItems, { printifyOrderId });

Expand All @@ -90,7 +96,7 @@ export const POST = async (request: NextRequest) => {

return NextResponse.json({ checkoutUrl: session.url });
} catch (error) {
console.error('Error processing request:', error);
console.error('[Checkout] Error processing checkout request:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
75 changes: 41 additions & 34 deletions src/app/api/v1/webhook/checkout/route.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,7 @@
import type { Stripe as StripeType } from 'stripe';
import { stripe } from '@/lib/stripe';
import { retrieveCheckoutSession, stripe } from '@/lib/stripe';
import { NextRequest, NextResponse } from 'next/server';

async function fulfillCheckout(printifyOrderId: string) {
console.log('Fulfilling Checkout Session - printifyOrderId:' + printifyOrderId);
// TODO_PRINTIFY
return;

// // TODO: Make this function safe to run multiple times,
// // even concurrently, with the same session ID

// // TODO: Make sure fulfillment hasn't already been
// // peformed for this Checkout Session

// const checkoutSession = await retrieveCheckoutSession(sessionId)
// const { shipping_details, line_items, metadata } = checkoutSession
// const { printifyOrderId } = metadata

// // Check the Checkout Session's payment_status property
// // to determine if fulfillment should be peformed
// if (checkoutSession.payment_status !== 'unpaid') {
// // TODO: Perform fulfillment of the line items

// // TODO: Record/save fulfillment status for this
// // Checkout Session
// await sendOrderToProduction(printifyOrderId)
}
import { sendOrderToProduction } from '@/lib/printify';

export const POST = async (request: NextRequest) => {
try {
Expand All @@ -38,19 +14,50 @@ export const POST = async (request: NextRequest) => {
const signature = request.headers.get('stripe-signature') as string;
const event: StripeType.Event = stripe.webhooks.constructEvent(body, signature, secret);

if (['checkout.session.completed', 'checkout.session.async_payment_succeeded'].includes(event.type)) {
// @ts-expect-error - acceptable error
const printifyOrderId = event.data.object?.metadata?.printifyOrderId;
if (!printifyOrderId) {
throw new Error(`missing printifyOrderId on metadata, ${event.id}`);
const permittedEvents: string[] = ['checkout.session.completed', 'payment_intent.succeeded', 'payment_intent.payment_failed'];
if (permittedEvents.includes(event.type)) {
let data;

switch (event.type) {
case 'checkout.session.completed':
data = event.data.object as StripeType.Checkout.Session;

const printifyOrderId = event.data.object?.metadata?.printifyOrderId;
if (!printifyOrderId) {
throw new Error(`missing printifyOrderId on metadata, ${data.id}`);
}

// Make sure fulfillment hasn't already been preformed for this Checkout Session
const checkoutSession = await retrieveCheckoutSession(data.id);
if (checkoutSession.payment_status === 'unpaid') {
console.error('[Stripe] Webhook error: Cannot fullfil an unpaid order');
return NextResponse.json({ message: 'Cannot fullfil an unpaid order' }, { status: 400 });
}

const { success } = await sendOrderToProduction(printifyOrderId);
if (!success) {
console.error('[Printify] unable to publish Printify order');
return NextResponse.json({ message: 'Unable to checkout cart items' }, { status: 400 });
}

return NextResponse.json({ result: event, ok: true });
case 'payment_intent.payment_failed':
data = event.data.object as StripeType.PaymentIntent;
console.error(`[Stripe Webhook Event] ❌ Payment failed: ${data.last_payment_error?.message}`);
break;
case 'payment_intent.succeeded':
data = event.data.object as StripeType.PaymentIntent;
console.info(`[Stripe Webhook Event] 💰 PaymentIntent status: ${data.status}`);
break;
default:
console.warn(`[Stripe Webhook Event] Unhandled event: ${event.type}`);
return NextResponse.json({ result: event, ok: true });
}

await fulfillCheckout(printifyOrderId);
}

return NextResponse.json({ result: event, ok: true });
} catch (error) {
console.error('Error processing request:', error);
console.error('[Stripe] Error processing webhook request:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
};
1 change: 1 addition & 0 deletions src/components/AddToCartBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const AddToCartBtn: React.FC<{ productId: string; size: string; price: number }>
price: price,
currency: PRODUCT_CONFIG.currency,
product_data: { productId, size, category: product.category, type: product.type },
quantity: 1,
};
addItem(cartItem);
};
Expand Down
83 changes: 83 additions & 0 deletions src/lib/printify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Printify from 'printify-sdk-js';
import { STICKER_SIZES } from '@/lib/products';
import { CartItem, PrintifyLineItem } from '@/types';

// docs: https://developers.printify.com/
// keys: https://printify.com/app/account/api
// NOTE: run this command to find your shopId: curl -X GET https://api.printify.com/v1/shops.json --header "Authorization: Bearer $PRINTIFY_API_TOKEN"
const printify = new Printify({
shopId: process.env.PRINTIFY_SHOP_ID as string, // global query by shop_id
accessToken: process.env.PRINTIFY_API_TOKEN as string,
});

// Hard-coded configuration for "Kiss Cut Stickers"
const PRINTIFY_BLUEPRINT_ID = 1268; // {"id":1268,"title":"Kiss-Cut Vinyl Decals"} - printify.catalog.listBlueprints()
const PRINTIFY_PRINT_PROVIDER_ID = 215; // {"id":215,"title":"Stickers & Posters"} - printify.catalog.getBlueprintProviders(blueprintId);
const PRINTIFY_VARIANT_IDS = {
// printify.catalog.getBlueprintVariants(blueprintId, printProviderId)
[STICKER_SIZES.TWO_BY_TWO_IN]: 95743,
[STICKER_SIZES.THREE_BY_THREE_IN]: 95744,
[STICKER_SIZES.FOUR_BY_FOUR_IN]: 95745,
};

export const formatCartItemsForPrintify = (cartItems: CartItem[]): PrintifyLineItem[] => {
return cartItems.map(cartItem => ({
print_provider_id: PRINTIFY_PRINT_PROVIDER_ID,
blueprint_id: PRINTIFY_BLUEPRINT_ID,
variant_id: PRINTIFY_VARIANT_IDS[cartItem.product_data.size],
print_areas: {
front: `${process.env.NEXT_PUBLIC_URL}${cartItem.image}`,
},
quantity: cartItem.quantity,
}));
};

export async function createDraftOrder(lineItems: PrintifyLineItem[]): Promise<{ printifyOrderId: string }> {
try {
const randomId = crypto.randomUUID();
const randomLabel = Math.floor(Math.random() * 100000)
.toString()
.padStart(5, '0');

const orderData = {
external_id: randomId,
label: `shipment_${randomLabel}`,
line_items: lineItems,
shipping_method: 1,
is_printify_express: false,
is_economy_shipping: false,
send_shipping_notification: false,
// TODO_AUTH_ORDER
address_to: {
first_name: 'John',
last_name: 'Doe',
email: '[email protected]',
phone: '0574 69 21 90',
country: 'BE',
region: '',
address1: 'ExampleBaan 121',
address2: '45',
city: 'Retie',
zip: '2470',
},
};

const result = await printify.orders.submit(orderData);
const { id: printifyOrderId } = result;
return { printifyOrderId };
} catch (error) {
console.error('[Printify] Error submitting order:', error);
return { printifyOrderId: '' };
}
}

export async function sendOrderToProduction(printifyOrderId: string): Promise<{ success: boolean }> {
try {
console.log('[Printify] sending order to product, printifyOrderId', printifyOrderId);
await printify.orders.sendToProduction(printifyOrderId);
return { success: true };
} catch (error) {
console.error('[Printify] Error sending order to production:', error);
return { success: false };
}
}
8 changes: 5 additions & 3 deletions src/lib/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ export const PRODUCT_CONFIG = {
defaultSize: STICKER_SIZES.TWO_BY_TWO_IN,
};

// SPOKE Custom Products

// hard-coded, based on cost per unit (Printify)
export const STICKER_PRICES = {
[STICKER_SIZES.TWO_BY_TWO_IN]: 230,
[STICKER_SIZES.THREE_BY_THREE_IN]: 250,
[STICKER_SIZES.FOUR_BY_FOUR_IN]: 270,
[STICKER_SIZES.TWO_BY_TWO_IN]: 230, // $1.45 (whole sale) + $0.30 + 2% (stripe fee)
[STICKER_SIZES.THREE_BY_THREE_IN]: 250, // $1.61 (whole sale) + $0.30 + 2% (stripe fee)
[STICKER_SIZES.FOUR_BY_FOUR_IN]: 270, // $2.02 (whole sale) + $0.30 + 2% (stripe fee)
};
export const DEFAULT_STICKER_SIZES = [
{
Expand Down
45 changes: 23 additions & 22 deletions src/lib/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import Stripe from 'stripe';
import { PRODUCT_CONFIG } from '@/lib/products';
import { CartItem } from '@/types';

// See your keys here: https://dashboard.stripe.com/apikeys
// docs: https://docs.stripe.com
// keys: https://dashboard.stripe.com/apikeys
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2024-06-20',
Expand Down Expand Up @@ -65,7 +66,7 @@ export async function createCheckoutSession(
shipping_address_collection: {
allowed_countries: PRODUCT_CONFIG.allowCountries as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry[],
},
// TODO_PRINTIFY - calculate this dynamically with Printify request
// TODO_PRINTIFY - calculate this dynamically with Printify request + USD 0.09 per item!
shipping_options: [
{
shipping_rate_data: {
Expand All @@ -87,26 +88,26 @@ export async function createCheckoutSession(
},
},
},
{
shipping_rate_data: {
type: 'fixed_amount',
fixed_amount: {
amount: 429,
currency: 'usd',
},
display_name: 'Economy',
delivery_estimate: {
minimum: {
unit: 'business_day',
value: 4,
},
maximum: {
unit: 'business_day',
value: 8,
},
},
},
},
// {
// shipping_rate_data: {
// type: 'fixed_amount',
// fixed_amount: {
// amount: 429,
// currency: 'usd',
// },
// display_name: 'Economy',
// delivery_estimate: {
// minimum: {
// unit: 'business_day',
// value: 4,
// },
// maximum: {
// unit: 'business_day',
// value: 8,
// },
// },
// },
// },
],
automatic_tax: {
enabled: true, // Enable tax based on location
Expand Down
12 changes: 11 additions & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export type CartItem = {
* The description of the product
*/
description: string;
quantity?: number;
quantity: number;
/**
* The price of the product
*/
Expand Down Expand Up @@ -66,3 +66,13 @@ export interface PaginatedProducts {
products: Product[];
pageLimitIsReached: boolean;
}

export interface PrintifyLineItem {
print_provider_id: number;
blueprint_id: number;
variant_id: number; // Assuming variantIds has numeric keys for sticker sizes
print_areas: {
front: string;
};
quantity: number;
}
2 changes: 1 addition & 1 deletion src/utils/validateCartItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const validateCartItems = (cartItems: CartItem[]): CartItem[] => {

return validCartItems;
} catch (error) {
console.error('Cart validation error:', error);
console.error('[CartValidation] Unable to verify cart items:', error);
return [];
}
};
Expand Down
Loading

0 comments on commit 6549e7d

Please sign in to comment.