add retries to order

This commit is contained in:
2026-01-15 19:58:57 -03:00
parent ca52384b53
commit 466936acf4
9 changed files with 402 additions and 273 deletions

View File

@@ -62,5 +62,13 @@ def payment_retries(
},
cond_expr='attribute_not_exists(sk)',
)
transact.update(
key=KeyPair(order_id, 'TRANSACTION#STATS'),
update_expr='SET updated_at = :now REMOVE last_attempt_succeeded',
expr_attr_values={
':now': now(),
},
cond_expr='attribute_exists(sk)',
)
return JSONResponse(status_code=HTTPStatus.CREATED)

View File

@@ -32,7 +32,13 @@ def test_payment_retries(
)
assert r['statusCode'] == HTTPStatus.CREATED
r = dynamodb_persistence_layer.collection.get_item(
r = dynamodb_persistence_layer.collection.get_items(
KeyPair('4b23f6f5-5377-476b-b1de-79427c0295f6', 'TRANSACTION')
+ KeyPair(
'4b23f6f5-5377-476b-b1de-79427c0295f6',
'TRANSACTION#STATS',
rename_key='stats',
)
)
assert r['credit_card']['number'] == '4111111111111111'
assert 'last_attempt_succeeded' not in r['stats']

View File

@@ -30,6 +30,7 @@
// file: tests/routes/orders/test_payment_retries.py
{"id": "4b23f6f5-5377-476b-b1de-79427c0295f6", "sk": "0", "installments": 3}
{"id": "4b23f6f5-5377-476b-b1de-79427c0295f6", "sk": "INVOICE", "invoice_id": "123"}
{"id": "4b23f6f5-5377-476b-b1de-79427c0295f6", "sk": "TRANSACTION#STATS", "last_attempt_succeeded": false}
// Indicies
// CNPJs

View File

@@ -1,23 +1,14 @@
import { useForm, Controller, useWatch, type Control } from 'react-hook-form'
import { PatternFormat } from 'react-number-format'
import { zodResolver } from '@hookform/resolvers/zod'
import { ErrorMessage } from '@hookform/error-message'
import { z } from 'zod'
import { ArrowRightIcon, CircleQuestionMarkIcon } from 'lucide-react'
import { zodResolver } from '@hookform/resolvers/zod'
import valid from 'card-validator'
import { ArrowRightIcon, CircleQuestionMarkIcon } from 'lucide-react'
import { Controller, useForm, useWatch, type Control } from 'react-hook-form'
import { PatternFormat } from 'react-number-format'
import { z } from 'zod'
import { Currency } from '@repo/ui/components/currency'
import { Button } from '@repo/ui/components/ui/button'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Label } from '@repo/ui/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@repo/ui/components/ui/radio-group'
import { Separator } from '@repo/ui/components/ui/separator'
import { Input } from '@repo/ui/components/ui/input'
import { Card, CardContent } from '@repo/ui/components/ui/card'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from '@repo/ui/components/ui/hover-card'
import {
Field,
FieldError,
@@ -25,18 +16,27 @@ import {
FieldLabel,
FieldSet
} from '@repo/ui/components/ui/field'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger
} from '@repo/ui/components/ui/hover-card'
import { Input } from '@repo/ui/components/ui/input'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Label } from '@repo/ui/components/ui/label'
import {
NativeSelect,
NativeSelectOption
} from '@repo/ui/components/ui/native-select'
import { Currency } from '@repo/ui/components/currency'
import { RadioGroup, RadioGroupItem } from '@repo/ui/components/ui/radio-group'
import { Separator } from '@repo/ui/components/ui/separator'
import { useWizard } from '@/components/wizard'
import { isName } from '../_.$orgid.users.add/data'
import { applyDiscount } from './discount'
import { useWizardStore } from './store'
const creditCard = z.object({
export const creditCardSchema = z.object({
holder_name: z
.string()
.trim()
@@ -71,7 +71,7 @@ const formSchema = z.discriminatedUnion(
z.object({
payment_method: z.literal('CREDIT_CARD'),
credit_card: creditCard,
credit_card: creditCardSchema,
installments: z.coerce.number().int().min(1).max(12)
})
],
@@ -80,7 +80,7 @@ const formSchema = z.discriminatedUnion(
type Schema = z.input<typeof formSchema>
export type CreditCard = z.infer<typeof creditCard>
export type CreditCard = z.infer<typeof creditCardSchema>
export function Payment({}) {
const wizard = useWizard()
@@ -95,6 +95,7 @@ export function Payment({}) {
resolver: zodResolver(formSchema)
})
const paymentMethod = useWatch({ control, name: 'payment_method' })
const total = subtotal + discount
const onSubmit = async ({ payment_method, ...data }: Schema) => {
if (payment_method === 'CREDIT_CARD') {
@@ -156,7 +157,54 @@ export function Payment({}) {
/>
{paymentMethod === 'CREDIT_CARD' ? (
<CreditCard control={control} total={subtotal + discount} />
<Card className="lg:w-1/2">
<CardContent className="space-y-6">
<CreditCard control={control} />
<Controller
control={control}
name="installments"
defaultValue={1}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Parcelas</FieldLabel>
<NativeSelect
id={field.name}
value={String(field.value)}
onChange={field.onChange}
aria-invalid={fieldState.invalid}
>
{Array.from({ length: 12 }, (_, index) => {
const installment = index + 1 // 1 -> 12
if (installment === 1) {
return (
<NativeSelectOption key={installment} value={1}>
<Currency>{total}</Currency> à vista
</NativeSelectOption>
)
}
const value =
installment > 1
? calcInterest(total, installment) / installment
: total
return (
<NativeSelectOption
key={installment}
value={installment}
>
{installment}x <Currency>{value}</Currency> com juros
</NativeSelectOption>
)
})}
</NativeSelect>
</Field>
)}
/>
</CardContent>
</Card>
) : null}
<Separator />
@@ -180,19 +228,11 @@ export function Payment({}) {
)
}
export function CreditCard({
total,
control
}: {
total: number
control: Control<Schema>
}) {
export function CreditCard({ control }: { control: Control<Schema> }) {
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 10 }, (_, i) => currentYear + i)
return (
<Card className="lg:w-1/2">
<CardContent>
<FieldGroup>
<FieldSet>
{/* Credir card number */}
@@ -217,9 +257,7 @@ export function CreditCard({
{...field}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
@@ -236,9 +274,7 @@ export function CreditCard({
aria-invalid={fieldState.invalid}
{...field}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
</Field>
)}
/>
@@ -320,14 +356,14 @@ export function CreditCard({
className="text-sm space-y-1.5 lg:w-78"
>
<p>
O <Kbd>CVC</Kbd> é o código de segurança do cartão
de crédito.
O <Kbd>CVC</Kbd> é o código de segurança do cartão de
crédito.
</p>
<p>
Ele fica no verso do cartão e geralmente possui{' '}
<Kbd>3 dígitos</Kbd> (ou <Kbd>4 dígitos</Kbd> na
frente, no caso do American Express).
<Kbd>3 dígitos</Kbd> (ou <Kbd>4 dígitos</Kbd> na frente,
no caso do American Express).
</p>
</HoverCardContent>
</HoverCard>
@@ -341,53 +377,8 @@ export function CreditCard({
)}
/>
</FieldSet>
<Controller
control={control}
name="installments"
defaultValue={1}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={field.name}>Parcelas</FieldLabel>
<NativeSelect
id={field.name}
value={String(field.value)}
onChange={field.onChange}
aria-invalid={fieldState.invalid}
>
{Array.from({ length: 12 }, (_, index) => {
const installment = index + 1 // 1 -> 12
if (installment === 1) {
return (
<NativeSelectOption key={installment} value={1}>
<Currency>{total}</Currency> à vista
</NativeSelectOption>
)
}
const value =
installment > 1
? calcInterest(total, installment) / installment
: total
return (
<NativeSelectOption
key={installment}
value={installment}
>
{installment}x <Currency>{value}</Currency> com juros
</NativeSelectOption>
)
})}
</NativeSelect>
</Field>
)}
/>
</FieldSet>
</FieldGroup>
</CardContent>
</Card>
)
}

View File

@@ -1,16 +1,34 @@
import { formatCEP } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRequest, useToggle } from 'ahooks'
import { PatternFormat } from 'react-number-format'
import valid from 'card-validator'
import { ExternalLinkIcon, PencilIcon, SearchIcon } from 'lucide-react'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { formatCEP } from '@brazilian-utils/brazilian-utils'
import { PatternFormat } from 'react-number-format'
import { z } from 'zod'
import valid from 'card-validator'
import { Currency } from '@repo/ui/components/currency'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Abbr } from '@repo/ui/components/abbr'
import { Currency } from '@repo/ui/components/currency'
import { Button } from '@repo/ui/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@repo/ui/components/ui/dialog'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle
} from '@repo/ui/components/ui/item'
import { Kbd } from '@repo/ui/components/ui/kbd'
import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import {
@@ -22,24 +40,6 @@ import {
TableHeader,
TableRow
} from '@repo/ui/components/ui/table'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle
} from '@repo/ui/components/ui/item'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@repo/ui/components/ui/dialog'
import { paymentMethods } from '@repo/ui/routes/orders/data'
import { useWizard } from '@/components/wizard'
@@ -59,8 +59,6 @@ import {
InputGroupInput
} from '@repo/ui/components/ui/input-group'
import { useWizardStore } from './store'
import { useParams } from 'react-router'
import {
Empty,
EmptyContent,
@@ -68,6 +66,8 @@ import {
EmptyHeader,
EmptyTitle
} from '@repo/ui/components/ui/empty'
import { useParams } from 'react-router'
import { useWizardStore } from './store'
type ReviewProps = {
onSubmit: () => void | Promise<void>
@@ -445,7 +445,7 @@ export function AddressDialog({ children }) {
tabIndex={-1}
className="text-black dark:text-white"
>
Cancel
Cancelar
</Button>
</DialogClose>

View File

@@ -1,10 +1,12 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { applyDiscount, type Coupon } from './discount'
import { calcInterest, type CreditCard } from './payment'
import type { PaymentMethod } from '@repo/ui/routes/orders/data'
import type { Enrollment } from '../_.$orgid.enrollments.add/data'
import type { Item } from './bulk'
import { applyDiscount, type Coupon } from './discount'
import { calcInterest, type CreditCard } from './payment'
import type { Address } from './review'
export type WizardState = {

View File

@@ -1,13 +1,17 @@
import type { Route } from './+types/route'
import { formatCEP } from '@brazilian-utils/brazilian-utils'
import { zodResolver } from '@hookform/resolvers/zod'
import { useRequest } from 'ahooks'
import {
AlertCircleIcon,
ArrowLeftRightIcon,
HelpCircleIcon
} from 'lucide-react'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { Link } from 'react-router'
import { z } from 'zod'
import { Currency } from '@repo/ui/components/currency'
import {
@@ -52,7 +56,23 @@ import {
type Order as Order_
} from '@repo/ui/routes/orders/data'
import type { CreditCard as CreditCard_ } from '../_.$orgid.enrollments.buy/payment'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from '@repo/ui/components/ui/dialog'
import { Separator } from '@repo/ui/components/ui/separator'
import { Spinner } from '@repo/ui/components/ui/spinner'
import {
CreditCard,
creditCardSchema,
type CreditCard as CreditCardProps
} from '../_.$orgid.enrollments.buy/payment'
import type { Address } from '../_.$orgid.enrollments.buy/review'
import { useWizardStore } from '../_.$orgid.enrollments.buy/store'
@@ -65,12 +85,12 @@ export function meta() {
}
const PaymentMethodComponent = {
PIX: Pix,
BANK_SLIP: BankSlip,
CREDIT_CARD: CreditCard
PIX: PixPaymentMethod,
BANK_SLIP: BankSlipPaymentMethod,
CREDIT_CARD: CreditCardPaymentMethod
}
type Item_ = {
type Item = {
id: string
name: string
unit_price: number
@@ -82,18 +102,24 @@ type User = {
name: string
}
type Invoice = {
invoice_id: string
secure_url: string
}
type Order = Order_ & {
items: Item_[]
items: Item[]
interest_amount: number
due_date: string
created_at: string
subtotal: number
discount: number
address: Address
credit_card?: CreditCard_
credit_card?: CreditCardProps
coupon?: string
installments?: number
created_by?: User
invoice: Invoice
}
export async function loader({ context, request, params }: Route.LoaderArgs) {
@@ -115,11 +141,15 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
payment_method,
interest_amount,
discount,
invoice,
subtotal,
items = []
} = order
const Component = PaymentMethodComponent[payment_method]
const Component =
(PaymentMethodComponent as Record<string, React.ComponentType<any>>)[
payment_method
] ?? UnknownPaymentMethod
useEffect(() => {
reset()
@@ -168,7 +198,9 @@ export default function Route({ loaderData: { order } }: Route.ComponentProps) {
<ItemContent>
<ItemTitle>Forma de pagamento</ItemTitle>
<div className="text-muted-foreground text-sm leading-normal font-normal text-balance">
{Component && <Component {...order} />}
{Component && (
<Component {...order} invoice_id={invoice['invoice_id']} />
)}
</div>
</ItemContent>
</Item>
@@ -271,23 +303,50 @@ function Status({ status: s }: { status: string }) {
}
type PaymentMethodProps = {
id: string
status: string
total: number
invoice_id: string
installments: number
}
type CreditCardProps = PaymentMethodProps & {
type BankSlipPaymentMethodProps = PaymentMethodProps & {}
function BankSlipPaymentMethod({ status }: BankSlipPaymentMethodProps) {
return (
<ul className="flex max-lg:flex-col gap-x-1.5">
<li>Boleto bancário</li>
<li>
<Status status={status} />
</li>
</ul>
)
}
type PixPaymentMethodrops = PaymentMethodProps & {}
function PixPaymentMethod({}: PixPaymentMethodrops) {
return <>Pix</>
}
function UnknownPaymentMethod() {
return <>Deposito bancário</>
}
type CreditCardPaymentMethodProps = PaymentMethodProps & {
stats: { last_attempt_succeeded: boolean }
credit_card: { last4: string; brand: string }
}
function CreditCard({
function CreditCardPaymentMethod({
id,
status,
total,
credit_card,
installments,
invoice_id,
stats
}: CreditCardProps) {
}: CreditCardPaymentMethodProps) {
return (
<>
<ul className="flex max-lg:flex-col gap-x-1.5">
@@ -295,12 +354,12 @@ function CreditCard({
{credit_card.brand} (Crédito) **** {credit_card.last4}
</li>
<li>
{!stats.last_attempt_succeeded ? (
{stats.last_attempt_succeeded === false ? (
<Badge
variant="outline"
className="text-red-400 border-red-400 px-1.5"
>
<AlertCircleIcon /> Transação negada
<AlertCircleIcon /> Pagamento não aprovado
</Badge>
) : (
<Status status={status} />
@@ -312,32 +371,94 @@ function CreditCard({
{installments}x <Currency>{total / Number(installments)}</Currency>
</p>
{!stats.last_attempt_succeeded ? (
{stats.last_attempt_succeeded === false ? (
<div className="flex justify-center mt-2">
<CreditCardPaymentDialog
id={id}
total={total}
installments={installments}
invoice_id={invoice_id}
>
<Button size="sm" variant="secondary" className="cursor-pointer">
<ArrowLeftRightIcon /> Tentar com outro cartão
<ArrowLeftRightIcon /> Pagar com outro cartão
</Button>
</CreditCardPaymentDialog>
</div>
) : null}
</>
)
}
type BankSlipProps = PaymentMethodProps & {}
const formSchema = z.object({
credit_card: creditCardSchema
})
type Schema = z.input<typeof formSchema>
function CreditCardPaymentDialog({
children,
id,
installments,
invoice_id,
total
}) {
const { runAsync } = useRequest(
async ({ credit_card }) => {
return await fetch(`/~/api/orders/${id}/payment-retries`, {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ credit_card, installments, invoice_id })
})
},
{ manual: true }
)
const { control, handleSubmit, formState } = useForm<Schema>({
resolver: zodResolver(formSchema)
})
const onSubmit = async ({ credit_card }: Schema) => {
const r = await runAsync({ credit_card })
console.log(r.ok)
console.log(await r.json())
}
function BankSlip({ status }: BankSlipProps) {
return (
<ul className="flex max-lg:flex-col gap-x-1.5">
<li>Boleto bancário</li>
<li>
<Status status={status} />
</li>
</ul>
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Novo cartão de crédito</DialogTitle>
<DialogDescription>
Use um novo cartão para concluir o pagamento. Nenhuma cobrança foi
realizada no cartão anterior.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<CreditCard control={control} />
<Separator />
<DialogFooter className="*:cursor-pointer">
<DialogClose asChild>
<Button
type="button"
variant="link"
className="text-black dark:text-white"
tabIndex={-1}
>
Cancelar
</Button>
</DialogClose>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting && <Spinner />}
Pagar <Currency>{total}</Currency>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
type PixProps = PaymentMethodProps & {}
function Pix({}: PixProps) {
return <>Pix</>
}

View File

@@ -67,7 +67,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
'id': order_id,
'sk': 'INVOICE',
'payment_method': payment_method,
'secure_id': invoice['secure_id'],
'invoice_id': invoice['secure_id'],
'secure_url': invoice['secure_url'],
'created_at': now_,
# Uncomment this when adding for multiple payment providers

View File

@@ -58,7 +58,7 @@ def test_create_bank_slip_invoice(
invoice['secure_url']
== 'https://checkout.iugu.com/invoices/16f7aa3d-2e0b-41e9-987b-1dc95b957456-d7a2'
)
assert invoice['secure_id'] == '16f7aa3d-2e0b-41e9-987b-1dc95b957456-d7a2'
assert invoice['invoice_id'] == '16f7aa3d-2e0b-41e9-987b-1dc95b957456-d7a2'
assert (
invoice['bank_slip']['bank_slip_url']
== 'https://boletos.iugu.com/v1/public/invoice/16f7aa3d-2e0b-41e9-987b-1dc95b957456-d7a2/bank_slip'