Skip to content

Commit

Permalink
feat: display orders in the credits page (#78)
Browse files Browse the repository at this point in the history
  • Loading branch information
altaywtf authored Dec 16, 2023
1 parent 9d744f0 commit e569613
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 16 deletions.
2 changes: 1 addition & 1 deletion app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type Stripe from 'stripe';
import { env } from '@/env.mjs';
import { HttpBadRequestError, HttpInternalServerError } from '@/lib/errors';
import { stripe } from '@/lib/services/stripe/client';
import { fulfillOrder } from '@/lib/services/stripe/order';
import { fulfillOrder } from '@/lib/services/stripe/orders';

export async function POST(req: NextRequest) {
const signature = req.headers.get('stripe-signature');
Expand Down
19 changes: 16 additions & 3 deletions app/credits/components/credit-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { createCheckoutSession } from '@/lib/services/stripe/checkout';
import { Badge, Button, Flex, Text } from '@radix-ui/themes';
import { atom, useAtom } from 'jotai';

import { getCurrencySymbol } from '../utils/get-currency-symbol';

type Props = {
amount: number;
currency: string;
id: string;
popular?: boolean;
quantity: number;
Expand All @@ -17,7 +20,16 @@ const checkoutStatusAtom = atom<
type: 'idle',
});

export function CreditListItem({ amount, id, popular, quantity }: Props) {
export function CreditListItem({
amount,
currency,
id,
popular,
quantity,
}: Props) {
const fixedAmount = amount / 100;
const symbolizedCurrency = getCurrencySymbol(currency);

const [checkoutStatus, setCheckoutStatus] = useAtom(checkoutStatusAtom);

const handleClick = async () => {
Expand Down Expand Up @@ -52,7 +64,8 @@ export function CreditListItem({ amount, id, popular, quantity }: Props) {
</Flex>

<Text color="gray" size="2">
${(amount / quantity).toFixed(2)} per episode summarization.
{symbolizedCurrency}
{(fixedAmount / quantity).toFixed(2)} per episode summarization.
</Text>
</Flex>

Expand All @@ -66,7 +79,7 @@ export function CreditListItem({ amount, id, popular, quantity }: Props) {
>
{checkoutStatus.type === 'loading' && checkoutStatus.id === id
? 'Loading...'
: `$${amount}`}
: `${symbolizedCurrency}${fixedAmount}`}
</Button>
</Flex>
);
Expand Down
46 changes: 46 additions & 0 deletions app/credits/components/order-list-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { Tables } from '@/types/supabase/database';

import { Button, Flex, Text } from '@radix-ui/themes';
import { format } from 'date-fns';
import Link from 'next/link';

import { getCurrencySymbol } from '../utils/get-currency-symbol';

type Props = Tables<'order'>;

export function OrderListItem(props: Props) {
const fixedAmount = props.amount / 100;
const symbolizedCurrency = getCurrencySymbol(props.currency);

return (
<Flex align="center" direction="row" justify="between">
<Flex direction="column" gap="1">
<Flex align="center" gap="1">
<Text size="3" weight="medium">
{props.credits} credits for{' '}
<Text>
{symbolizedCurrency}
{fixedAmount}
</Text>
</Text>
</Flex>

<Text color="gray" size="2">
{format(new Date(props.created_at), 'HH:mm - MMM d, yyyy')}
</Text>
</Flex>

<Button
asChild
highContrast
size="2"
style={{ minWidth: 64 }}
variant="soft"
>
<Link href={props.invoice_url} target="_blank">
Receipt
</Link>
</Button>
</Flex>
);
}
36 changes: 29 additions & 7 deletions app/credits/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fetchAccountAICredits } from '@/lib/services/account';
import { getPrices } from '@/lib/services/stripe/prices';
import { createSupabaseServerClient } from '@/lib/services/supabase/server';
import {
CalloutIcon,
CalloutRoot,
Expand All @@ -11,9 +12,11 @@ import {
Text,
} from '@radix-ui/themes';
import { Provider } from 'jotai';
import { cookies } from 'next/headers';
import { FaCircleCheck } from 'react-icons/fa6';

import { CreditListItem } from './components/credit-list-item';
import { OrderListItem } from './components/order-list-item';

type Props = {
searchParams: {
Expand All @@ -22,16 +25,16 @@ type Props = {
};

export default async function Page(props: Props) {
const supabase = createSupabaseServerClient(cookies());
const credits = await fetchAccountAICredits();
const prices = await getPrices();
const ordersQuery = await supabase
.from('order')
.select('*')
.order('created_at', { ascending: false });

return (
<Flex
direction="column"
gap="4"
mx="auto"
style={{ maxWidth: 'var(--container-1)' }}
>
<Flex direction="column" gap="5">
{props.searchParams.status === 'success' ? (
<CalloutRoot color="green">
<CalloutIcon>
Expand Down Expand Up @@ -61,7 +64,8 @@ export default async function Page(props: Props) {
<Flex direction="column" gap="4">
{prices.map((price, index) => (
<CreditListItem
amount={price.unit_amount! / 100}
amount={price.unit_amount!}

Check warning on line 67 in app/credits/page.tsx

View workflow job for this annotation

GitHub Actions / lint_and_typecheck

Forbidden non-null assertion
currency={price.currency}
id={price.id}
key={price.id}
popular={index === 1}
Expand All @@ -72,6 +76,24 @@ export default async function Page(props: Props) {
</Provider>
</Flex>
</Card>

{(ordersQuery.data || []).length > 0 ? (
<Card size="3">
<Flex direction="column" gap="4">
<Heading as="h3" size="4" trim="both">
Orders
</Heading>

<Separator orientation="horizontal" size="4" />

<Flex direction="column" gap="4">
{(ordersQuery.data || []).map((order) => (
<OrderListItem key={order.id} {...order} />
))}
</Flex>
</Flex>
</Card>
) : null}
</Flex>
);
}
14 changes: 14 additions & 0 deletions app/credits/utils/get-currency-symbol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function getCurrencySymbol(currency: string): string {
const normalizedCurrency = currency.toUpperCase();

switch (normalizedCurrency) {
case 'USD':
return '$';
case 'EUR':
return '€';
case 'GBP':
return '£';
default:
return normalizedCurrency;
}
}
35 changes: 30 additions & 5 deletions lib/services/stripe/order.ts → lib/services/stripe/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import { DatabaseError } from '@/lib/errors';
import { z } from 'zod';

import { createSupabaseServiceClient } from '../supabase/service';
import { stripe } from './client';

const checkoutSessionSchema = z.object({
amount_total: z.number(),
currency: z.string(),
id: z.string(),
line_items: z.object({
data: z.array(
Expand All @@ -22,9 +25,10 @@ const checkoutSessionSchema = z.object({
accountId: z.string().min(1).transform(Number),
userId: z.string().min(1),
}),
payment_intent: z.string(),
});

export const fulfillOrder = async (session: Stripe.Checkout.Session) => {
export async function fulfillOrder(session: Stripe.Checkout.Session) {
const validatedSession = checkoutSessionSchema.safeParse(session);

if (!validatedSession.success) {
Expand All @@ -43,17 +47,38 @@ export const fulfillOrder = async (session: Stripe.Checkout.Session) => {
throw new DatabaseError(currentAccountCreditsQuery.error);
}

const purchasedCredits =
validatedSession.data.line_items.data[0].price.transform_quantity.divide_by;

const { data: charges } = await stripe.charges.list({
payment_intent: validatedSession.data.payment_intent,
});

const charge = charges[0];

const createOrderQuery = await supabase.from('order').insert({
account: validatedSession.data.metadata.accountId,
amount: validatedSession.data.amount_total,
credits: purchasedCredits,
currency: validatedSession.data.currency,
invoice_url: charge.receipt_url ?? '',
reference_id: validatedSession.data.id,
status: 'paid',
});

if (createOrderQuery.error) {
throw new DatabaseError(createOrderQuery.error);
}

const updateAccountQuery = await supabase
.from('account')
.update({
ai_credit:
(currentAccountCreditsQuery.data.ai_credit ?? 0) +
validatedSession.data.line_items.data[0].price.transform_quantity
.divide_by,
(currentAccountCreditsQuery.data.ai_credit ?? 0) + purchasedCredits,
})
.eq('id', validatedSession.data.metadata.accountId);

if (updateAccountQuery.error) {
throw new DatabaseError(updateAccountQuery.error);
}
};
}
47 changes: 47 additions & 0 deletions types/supabase/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,18 +240,21 @@ export interface Database {
Row: {
created_at: string;
id: number;
podcast_index_guid: string;
show: number;
spotify_id: string;
};
Insert: {
created_at?: string;
id?: number;
podcast_index_guid: string;
show: number;
spotify_id: string;
};
Update: {
created_at?: string;
id?: number;
podcast_index_guid?: string;
show?: number;
spotify_id?: string;
};
Expand All @@ -265,6 +268,50 @@ export interface Database {
},
];
};
order: {
Row: {
account: number;
amount: number;
created_at: string;
credits: number;
currency: string;
id: number;
invoice_url: string;
reference_id: string;
status: string;
};
Insert: {
account: number;
amount: number;
created_at?: string;
credits: number;
currency: string;
id?: number;
invoice_url?: string;
reference_id: string;
status: string;
};
Update: {
account?: number;
amount?: number;
created_at?: string;
credits?: number;
currency?: string;
id?: number;
invoice_url?: string;
reference_id?: string;
status?: string;
};
Relationships: [
{
foreignKeyName: 'order_account_fkey';
columns: ['account'];
isOneToOne: false;
referencedRelation: 'account';
referencedColumns: ['id'];
},
];
};
show: {
Row: {
created_at: string;
Expand Down

0 comments on commit e569613

Please sign in to comment.