Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: display orders in the credits page #78

Merged
merged 4 commits into from
Dec 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
};

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,17 +64,36 @@
<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}
quantity={price.transform_quantity!.divide_by}

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

View workflow job for this annotation

GitHub Actions / lint_and_typecheck

Forbidden non-null assertion
/>
))}
</Flex>
</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
Loading