11// Server-to-server CoinPayPortal payments for the $1 account-setup fee.
22//
3- // Contract (from the coinpayportal repo) :
3+ // The HTTP client is `createCoinPayClient` from @profullstack/stack/coinpay :
44// POST <ISSUER>/api/payments/create Authorization: Bearer <cp_live_ key>
5- // body { amount, blockchain, description, metadata, redirect_url, business_id? }
6- // -> 201 { payment: { id, status, ... } }
5+ // -> { payment: { id, status, ... } }
76// Hosted pay page = <ISSUER>/pay/<id> (constructed from the returned id)
87// Confirmation = webhook signed "X-CoinPay-Signature: t=<ts>,v1=<hmac>"
9- // (HMAC-SHA256 of "<ts>.<rawBody>"), event "payment.confirmed".
8+ // (verified with verifyCoinPayWebhook in the webhook route),
9+ // event "payment.confirmed".
1010//
1111// When COINPAY_API_KEY is unset (local dev / not yet provisioned) payConfigured()
1212// is false and callers auto-activate instead of charging, so the flow is testable
1313// offline. The $1 fee is collected to moshcoding's own business payout wallet — a
1414// per-user payout wallet is captured on the account for the user's OWN future
1515// earnings, not for this charge.
16- import crypto from "node:crypto " ;
16+ import { createCoinPayClient } from "@profullstack/stack/coinpay " ;
1717
1818const ISSUER = ( process . env . COINPAY_ISSUER || "https://coinpayportal.com" ) . replace ( / \/ + $ / , "" ) ;
1919const API_KEY = process . env . COINPAY_API_KEY || "" ;
2020const BUSINESS_ID = process . env . COINPAY_BUSINESS_ID || "" ;
21- const WEBHOOK_SECRET = process . env . COINPAY_WEBHOOK_SECRET || "" ;
2221const PAY_CHAIN = process . env . COINPAY_PAY_CHAIN || "USDC_POL" ;
2322
2423export const SETUP_FEE_USD = process . env . SETUP_FEE_USD || "1.00" ;
@@ -41,50 +40,19 @@ export async function createSetupPayment(opts: {
4140 redirectUrl ?: string ;
4241 amount ?: string ;
4342} ) : Promise < CreatedPayment > {
44- const body : Record < string , unknown > = {
45- amount : opts . amount || SETUP_FEE_USD ,
46- blockchain : PAY_CHAIN ,
43+ // Lazy construction: createCoinPayClient throws without an apiKey, and this
44+ // module is imported even when CoinPay isn't configured (offline dev mode).
45+ const coinpay = createCoinPayClient ( { apiKey : API_KEY , baseUrl : ISSUER } ) ;
46+ const { paymentId, payment } = await coinpay . createCheckout ( {
47+ amountUsd : Number ( opts . amount || SETUP_FEE_USD ) ,
48+ currency : PAY_CHAIN . toLowerCase ( ) ,
49+ paymentMethod : "crypto" ,
4750 description : `moshcoding account setup — ${ opts . domain } ` ,
4851 metadata : { kind : "account_setup" , account_id : opts . accountId , email : opts . email , domain : opts . domain } ,
49- } ;
50- if ( opts . redirectUrl ) body . redirect_url = opts . redirectUrl ;
51- if ( BUSINESS_ID ) body . business_id = BUSINESS_ID ;
52-
53- const res = await fetch ( `${ ISSUER } /api/payments/create` , {
54- method : "POST" ,
55- headers : { "content-type" : "application/json" , authorization : `Bearer ${ API_KEY } ` } ,
56- body : JSON . stringify ( body ) ,
52+ ...( opts . redirectUrl ? { redirectUrl : opts . redirectUrl } : { } ) ,
53+ ...( BUSINESS_ID ? { businessId : BUSINESS_ID } : { } ) ,
5754 } ) ;
58- if ( ! res . ok ) {
59- const detail = await res . text ( ) . catch ( ( ) => "" ) ;
60- throw new Error ( `coinpay create ${ res . status } : ${ detail . slice ( 0 , 200 ) } ` ) ;
61- }
62- const data = ( await res . json ( ) . catch ( ( ) => ( { } ) ) ) as { payment ?: { id ?: string ; status ?: string } } ;
63- const id = data . payment ?. id ;
64- if ( ! id ) throw new Error ( "coinpay: no payment id in response" ) ;
65- return { id, status : data . payment ?. status || "pending" , payUrl : payUrl ( id ) } ;
66- }
67-
68- /**
69- * Verifies an "X-CoinPay-Signature: t=<ts>,v1=<hex>" header. HMAC-SHA256 over
70- * "<ts>.<rawBody>" with the shared webhook secret, 5-minute timestamp tolerance,
71- * constant-time compare. Returns false when no secret is configured.
72- */
73- export function verifyWebhook ( rawBody : string , sigHeader : string | null ) : boolean {
74- if ( ! WEBHOOK_SECRET || ! sigHeader ) return false ;
75- const parts : Record < string , string > = { } ;
76- for ( const kv of sigHeader . split ( "," ) ) {
77- const i = kv . indexOf ( "=" ) ;
78- if ( i > - 1 ) parts [ kv . slice ( 0 , i ) . trim ( ) ] = kv . slice ( i + 1 ) . trim ( ) ;
79- }
80- const { t, v1 } = parts ;
81- if ( ! t || ! v1 ) return false ;
82- const ts = Number ( t ) ;
83- if ( ! Number . isFinite ( ts ) || Math . abs ( Date . now ( ) / 1000 - ts ) > 300 ) return false ;
84- const expected = crypto . createHmac ( "sha256" , WEBHOOK_SECRET ) . update ( `${ t } .${ rawBody } ` ) . digest ( "hex" ) ;
85- const a = Buffer . from ( v1 ) ;
86- const b = Buffer . from ( expected ) ;
87- return a . length === b . length && crypto . timingSafeEqual ( a , b ) ;
55+ return { id : paymentId , status : payment . status || "pending" , payUrl : payUrl ( paymentId ) } ;
8856}
8957
9058const PAID = new Set ( [ "confirmed" , "forwarded" , "completed" ] ) ;
@@ -96,10 +64,9 @@ export function isPaidStatus(s: unknown): boolean {
9664/** GET <ISSUER>/api/payments/<id> — poll fallback when a webhook is missed. */
9765export async function fetchPaymentStatus ( id : string ) : Promise < string | null > {
9866 try {
99- const res = await fetch ( `${ ISSUER } /api/payments/${ encodeURIComponent ( id ) } ` ) ;
100- if ( ! res . ok ) return null ;
101- const data = ( await res . json ( ) ) as { payment ?: { status ?: string } } ;
102- return data . payment ?. status || null ;
67+ const coinpay = createCoinPayClient ( { apiKey : API_KEY , baseUrl : ISSUER } ) ;
68+ const { status } = await coinpay . getCheckout ( id ) ;
69+ return status || null ;
10370 } catch {
10471 return null ;
10572 }
0 commit comments