/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' import * as z from 'zod' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' import { SettingsForm } from '../components/settings-form-layout' import { SettingsPageActionsPortal } from '../components/settings-page-context' import { removeTrailingSlash } from './utils' import { type CatalogStore, type PairOrphanError, type PairResult, createWaffoPancakePair, listWaffoPancakeCatalog, saveWaffoPancakeConfig, } from './waffo-pancake-api' // Only operator-typed fields. Nothing else lands in OptionMap until Save. const waffoPancakeSchema = z.object({ WaffoPancakeMerchantID: z.string(), WaffoPancakePrivateKey: z.string(), }) export type WaffoPancakeSettingsValues = z.infer & { WaffoPancakeReturnURL: string } interface Props { defaultValues: WaffoPancakeSettingsValues provisionedStoreID?: string provisionedProductID?: string } const PANCAKE_DASHBOARD_URL = 'https://pancake.waffo.ai/merchant/dashboard' const DEFAULT_NEW_STORE_NAME = 'new-api-store' const DEFAULT_NEW_PRODUCT_NAME = 'new-api-charge-product' const DEFAULT_NEW_PAIR_NAME = `${DEFAULT_NEW_STORE_NAME} + ${DEFAULT_NEW_PRODUCT_NAME}` export function WaffoPancakeSettingsSection(props: Props) { const { t } = useTranslation() const [storeID, setStoreID] = React.useState(props.provisionedStoreID ?? '') const [productID, setProductID] = React.useState( props.provisionedProductID ?? '' ) const [phase, setPhase] = React.useState<'idle' | 'verifying' | 'saving'>( 'idle' ) const [catalog, setCatalog] = React.useState([]) // Seed dropdowns from saved bindings so they render on first paint instead // of waiting for the async catalog fetch to confirm them. const [chosenStoreID, setChosenStoreID] = React.useState( props.provisionedStoreID ?? '' ) const [chosenProductID, setChosenProductID] = React.useState( props.provisionedProductID ?? '' ) const [returnURL, setReturnURL] = React.useState( props.defaultValues.WaffoPancakeReturnURL ?? '' ) const [creatingPair, setCreatingPair] = React.useState(false) const initialRef = React.useRef(props.defaultValues) const defaultsSignature = React.useMemo( () => JSON.stringify(props.defaultValues), [props.defaultValues] ) // "merchantID|privateKey" of the last verified pair; debounced verify // skips when nothing changed. const lastVerifiedSignature = React.useRef('') const fetchSerialRef = React.useRef(0) const form = useForm({ resolver: zodResolver(waffoPancakeSchema), mode: 'onChange', defaultValues: { WaffoPancakeMerchantID: props.defaultValues.WaffoPancakeMerchantID, WaffoPancakePrivateKey: props.defaultValues.WaffoPancakePrivateKey, }, }) // Mount-only — never re-sync from props after the first render. The // backend strips PrivateKey from GET /api/option/, so a re-sync would // wipe whatever the operator just typed. const didMountRef = React.useRef(false) React.useEffect(() => { const parsed = JSON.parse(defaultsSignature) as WaffoPancakeSettingsValues initialRef.current = parsed if (didMountRef.current) return didMountRef.current = true form.reset({ WaffoPancakeMerchantID: parsed.WaffoPancakeMerchantID, WaffoPancakePrivateKey: parsed.WaffoPancakePrivateKey, }) setReturnURL(parsed.WaffoPancakeReturnURL ?? '') lastVerifiedSignature.current = `${parsed.WaffoPancakeMerchantID.trim()}|${parsed.WaffoPancakePrivateKey.trim()}` }, [defaultsSignature, form]) React.useEffect(() => { setStoreID(props.provisionedStoreID ?? '') }, [props.provisionedStoreID]) React.useEffect(() => { setProductID(props.provisionedProductID ?? '') }, [props.provisionedProductID]) const productsForChosenStore = React.useMemo(() => { if (!chosenStoreID) return [] return catalog.find((s) => s.id === chosenStoreID)?.onetimeProducts ?? [] }, [catalog, chosenStoreID]) // Raw-ID fallback items render the trigger before the catalog loads or // when the saved entity has been deleted upstream. const storeSelectItems = React.useMemo(() => { const items = catalog.map((s) => ({ value: s.id, label: `${s.name} (${s.id})`, })) if (chosenStoreID && !catalog.some((s) => s.id === chosenStoreID)) { items.push({ value: chosenStoreID, label: chosenStoreID }) } return items }, [catalog, chosenStoreID]) const productSelectItems = React.useMemo(() => { const items = productsForChosenStore.map((p) => ({ value: p.id, label: `${p.name} (${p.id})`, })) if ( chosenProductID && !productsForChosenStore.some((p) => p.id === chosenProductID) ) { items.push({ value: chosenProductID, label: chosenProductID }) } return items }, [productsForChosenStore, chosenProductID]) // Verifies typed creds against Pancake (via /catalog) and refreshes the // dropdown options. `preselect` overrides the post-load anchor selection; // omitting it defaults to: saved binding → first store with products. const verifyAndFetchCatalog = React.useCallback( async ( merchantID: string, privateKey: string, preselect?: { storeID?: string; productID?: string } ) => { const serial = ++fetchSerialRef.current let stores: CatalogStore[] = [] try { const body = await listWaffoPancakeCatalog(merchantID, privateKey) if (serial !== fetchSerialRef.current) return if ( body?.message === 'success' && typeof body.data === 'object' && body.data ) { stores = (body.data as { stores: CatalogStore[] }).stores ?? [] } else { const reason = typeof body?.data === 'string' ? body.data : undefined toast.error( reason ? `${t('Credentials verification failed')}: ${reason}` : t( 'Credentials verification failed — double-check Merchant ID and API private key.' ) ) setPhase('idle') return } } catch (err) { if (serial !== fetchSerialRef.current) return toast.error( `${t('Credentials verification failed')}: ${ err instanceof Error ? err.message : String(err) }` ) setPhase('idle') return } if (serial !== fetchSerialRef.current) return setCatalog(stores) if (preselect) { setChosenStoreID(preselect.storeID ?? '') setChosenProductID(preselect.productID ?? '') } else { // Default anchor: bound product if found, else first product of // the first store with any — saves a click for new operators. const boundStore = stores.find((s) => s.onetimeProducts.some((p) => p.id === productID) ) if (boundStore && productID) { setChosenStoreID(boundStore.id) setChosenProductID(productID) } else { const storeWithProducts = stores.find( (s) => s.onetimeProducts.length > 0 ) if (storeWithProducts) { setChosenStoreID(storeWithProducts.id) setChosenProductID(storeWithProducts.onetimeProducts[0].id) } else { setChosenStoreID('') setChosenProductID('') } } } setPhase('idle') }, [productID, t] ) const watchedMerchantID = form.watch('WaffoPancakeMerchantID') || '' const watchedPrivateKey = form.watch('WaffoPancakePrivateKey') || '' React.useEffect(() => { const m = watchedMerchantID.trim() const k = watchedPrivateKey.trim() if (!m || !k) return const signature = `${m}|${k}` if (signature === lastVerifiedSignature.current) return const timer = setTimeout(() => { lastVerifiedSignature.current = signature setPhase('verifying') void verifyAndFetchCatalog(m, k) }, 800) return () => clearTimeout(timer) }, [watchedMerchantID, watchedPrivateKey, verifyAndFetchCatalog]) // Initial-load verify: GET /api/option/ strips PrivateKey so a returning // admin opens the page with empty key. Send blank creds in the body — // the catalog controller falls back to the persisted OptionMap creds. const initialLoadRef = React.useRef(false) React.useEffect(() => { if (initialLoadRef.current) return if (!props.defaultValues.WaffoPancakeMerchantID.trim()) return initialLoadRef.current = true setPhase('verifying') void verifyAndFetchCatalog('', '') }, [props.defaultValues.WaffoPancakeMerchantID, verifyAndFetchCatalog]) // Returns typed creds when the operator edited either field; otherwise // blanks so the backend falls back to persisted creds. Without this, // returning admins (saved merchant ID but empty key field) would send // a mixed-state body that the backend rejects. const readCreds = () => { const formMerchant = (form.getValues('WaffoPancakeMerchantID') || '').trim() const formKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() const saved = (props.defaultValues.WaffoPancakeMerchantID || '').trim() const edited = formMerchant !== saved || formKey.length > 0 if (!edited) return { merchantID: '', privateKey: '' } return { merchantID: formMerchant, privateKey: formKey } } // The minted product's SuccessURL is pinned to the current Return URL // field, so we prompt before creating when that field is empty. const handleCreatePair = async () => { if (!credsReady) { toast.error( t('Fill in both Merchant ID and API Private Key before creating.') ) return } const { merchantID, privateKey } = readCreds() const trimmedReturn = removeTrailingSlash(returnURL.trim()) if (!trimmedReturn) { if ( !window.confirm( t( 'Payment return URL is empty. Create the product without a SuccessURL redirect?' ) ) ) { return } } setCreatingPair(true) try { const body = await createWaffoPancakePair({ merchantID, privateKey, returnURL: trimmedReturn, }) if ( body?.message === 'success' && typeof body.data === 'object' && body.data ) { const created = body.data as PairResult // Refetch from GraphQL rather than trusting the response body so the // dropdowns reflect authoritative state, then anchor on minted IDs. setPhase('verifying') await verifyAndFetchCatalog(merchantID, privateKey, { storeID: created.store_id, productID: created.product_id, }) toast.success( `${t('Store + product created')}: ${created.store_id} / ${created.product_id}` ) return } const errData = body && typeof body.data === 'object' && body.data !== null ? (body.data as PairOrphanError) : null if (errData?.orphan_store && errData.store_id) { setPhase('verifying') await verifyAndFetchCatalog(merchantID, privateKey, { storeID: errData.store_id, productID: '', }) } const reason = errData?.error ?? (typeof body?.data === 'string' ? body.data : undefined) toast.error( reason ? `${t('Creation failed')}: ${reason}` : t('Creation failed') ) } catch (err) { toast.error( `${t('Creation failed')}: ${err instanceof Error ? err.message : String(err)}` ) } finally { setCreatingPair(false) } } const handleSave = async () => { // Sends raw form values (not readCreds): SaveWaffoPancakeConfig already // treats a blank PrivateKey as "keep existing", and MerchantID stays // populated from props for returning admins. const merchantID = (form.getValues('WaffoPancakeMerchantID') || '').trim() const privateKey = (form.getValues('WaffoPancakePrivateKey') || '').trim() if (!merchantID) { toast.error(t('Merchant ID is required')) return } if (!chosenStoreID || !chosenProductID) { toast.error(t('Pick or create both a store and a product before saving.')) return } setPhase('saving') try { const body = await saveWaffoPancakeConfig({ merchantID, privateKey, returnURL: removeTrailingSlash(returnURL.trim()), storeID: chosenStoreID, productID: chosenProductID, }) if ( body?.message === 'success' && typeof body.data === 'object' && body.data ) { const saved = body.data as { product_id: string; store_id: string } setStoreID(saved.store_id) setProductID(saved.product_id) toast.success(t('Waffo Pancake settings saved')) } else { const reason = typeof body?.data === 'string' ? body.data : undefined toast.error( reason ? `${t('Waffo Pancake save failed')}: ${reason}` : t('Waffo Pancake save failed') ) } } catch (err) { toast.error( `${t('Waffo Pancake save failed')}: ${ err instanceof Error ? err.message : String(err) }` ) } finally { setPhase('idle') } } const verifying = phase === 'verifying' const saving = phase === 'saving' // "Not edited" = MerchantID unchanged AND PrivateKey field blank, in // which case the backend falls back to persisted creds. Otherwise we // require both fields filled (mixed states would fail signature check). const savedMerchantID = ( props.defaultValues.WaffoPancakeMerchantID || '' ).trim() const formMerchantID = watchedMerchantID.trim() const formPrivateKey = watchedPrivateKey.trim() const credsEdited = formMerchantID !== savedMerchantID || formPrivateKey.length > 0 const hasSavedCreds = savedMerchantID.length > 0 const credsReady = credsEdited ? formMerchantID.length > 0 && formPrivateKey.length > 0 : hasSavedCreds const hasCatalog = catalog.length > 0 let bindStatusMessage: string if (!credsReady) { bindStatusMessage = t('Fill in the credentials above to begin.') } else if (verifying) { bindStatusMessage = t( 'Verifying credentials and pulling stores from your Pancake account...' ) } else if (hasCatalog) { bindStatusMessage = t( 'Mint a fresh pair below — or pick an existing one further down. Click Save when ready.' ) } else { bindStatusMessage = t( 'No stores on this merchant yet. Set a return URL and click Create to mint your first pair.' ) } return (

{t('Waffo Pancake MoR')}

{t( 'Start collecting payments globally without registering a company. Built for indie developers, OPC sole proprietorships, and startups. Waffo Pancake acts as your Merchant of Record, taking on the compliance burden of global payment collection — consumption tax, invoicing, subscription management, refunds, and chargebacks. Solo developers can launch fast and stay focused on product instead of compliance. Onboard in minutes — one prompt to a full integration.' )}

e.preventDefault()} className='gap-y-4' data-no-autosubmit='true' > {/* Blue box — webhook configuration only. */}

{t('Webhook Configuration:')}

  • {t('Webhook URL (Test):')}{' '} {'/api/waffo-pancake/webhook/test'}
  • {t('Webhook URL (Production):')}{' '} {'/api/waffo-pancake/webhook/prod'}
  • {t( 'Register each URL into the matching Test Mode / Production Mode webhook slot in the Pancake dashboard. Separate endpoints prevent test traffic from accidentally crediting production accounts.' )}
  • {t('Configure at:')}{' '} {t('Waffo Pancake Dashboard')}
( {t('Merchant ID')} field.onChange(event.target.value)} /> )} /> ( {t('API Private Key')}