Skip to content
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
22 changes: 22 additions & 0 deletions app/api/log/payment/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ type Payload = {
customer?: Address;
feeReceiver?: Address;
feeAmount?: string;
// 売上総額 (gross・raw) と全経路横断のネットワーク手数料相当額 (raw)、内訳版 (v3)。
saleAmount?: string;
networkFeeEquivalent?: string;
feeBreakdownVersion?: number;
userOpHash?: Hex;
txHash?: Hex;
feeTxHash?: Hex;
Expand Down Expand Up @@ -84,6 +88,19 @@ function validate(raw: unknown): Payload | null {
if (r.customer !== undefined && !validAddress(r.customer)) return null;
if (r.feeReceiver !== undefined && !validAddress(r.feeReceiver)) return null;
if (r.feeAmount !== undefined && !isDecimalString(r.feeAmount)) return null;
if (r.saleAmount !== undefined && !isDecimalString(r.saleAmount)) return null;
if (
r.networkFeeEquivalent !== undefined &&
!isDecimalString(r.networkFeeEquivalent)
)
return null;
if (
r.feeBreakdownVersion !== undefined &&
(typeof r.feeBreakdownVersion !== 'number' ||
!Number.isInteger(r.feeBreakdownVersion) ||
r.feeBreakdownVersion < 0)
)
return null;
if (r.userOpHash !== undefined && !validHex(r.userOpHash)) return null;
if (r.txHash !== undefined && !validHex(r.txHash)) return null;
if (r.feeTxHash !== undefined && !validHex(r.feeTxHash)) return null;
Expand Down Expand Up @@ -136,6 +153,11 @@ function validate(raw: unknown): Payload | null {
if (r.customer !== undefined) clean.customer = r.customer;
if (r.feeReceiver !== undefined) clean.feeReceiver = r.feeReceiver;
if (r.feeAmount !== undefined) clean.feeAmount = r.feeAmount;
if (r.saleAmount !== undefined) clean.saleAmount = r.saleAmount;
if (r.networkFeeEquivalent !== undefined)
clean.networkFeeEquivalent = r.networkFeeEquivalent;
if (r.feeBreakdownVersion !== undefined)
clean.feeBreakdownVersion = r.feeBreakdownVersion;
if (r.userOpHash !== undefined) clean.userOpHash = r.userOpHash;
if (r.txHash !== undefined) clean.txHash = r.txHash;
if (r.feeTxHash !== undefined) clean.feeTxHash = r.feeTxHash;
Expand Down
66 changes: 61 additions & 5 deletions app/api/log/payment/stats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ type LogEntry = {
tokenAddress?: string;
merchantAmount?: string;
feeAmount?: string;
// v3 fee/gas 分離フィールド。saleAmount = 売上総額 (gross・GMV の基礎)、
// networkFeeEquivalent = 非 circle のネットワーク手数料相当額、feeBreakdownVersion を
// 持つ log は分離記録済 (feeAmount = サービス料のみ)。持たない旧 log は feeAmount が
// conflated (gas 混在) のため、利用手数料 total から除外する。
saleAmount?: string;
networkFeeEquivalent?: string;
feeBreakdownVersion?: number;
// phase 2 cross-chain bridge fields。direct (= bridge 経由でない) 同一 chain
// 送金は両者とも undefined、Gateway / CCTP V2 経由なら値が入る。
bridge?: string;
Expand Down Expand Up @@ -95,6 +102,9 @@ type TokenAgg = {
errorCount: number;
totalMerchantWei: bigint;
totalFeeWei: bigint;
// v3: 売上総額 (gross・GMV) と全経路横断のネットワーク手数料相当額。
totalSaleWei: bigint;
totalNetworkFeeWei: bigint;
};

// "direct" = bridge 経由でない同一 chain transfer (entry.bridge undefined / 'none')。
Expand All @@ -108,6 +118,8 @@ type BridgeAgg = {
errorCount: number;
totalMerchantWei: bigint;
totalFeeWei: bigint;
totalSaleWei: bigint;
totalNetworkFeeWei: bigint;
};

type ChainAgg = {
Expand All @@ -118,6 +130,11 @@ type ChainAgg = {
errorCount: number;
totalMerchantWei: bigint;
totalFeeWei: bigint;
// v3: 売上総額 (gross・GMV)、全経路横断のネットワーク手数料相当額、内訳不明 (旧 log・
// feeBreakdownVersion 不在) の success 件数 (利用手数料 total から除外した分の可視化)。
totalSaleWei: bigint;
totalNetworkFeeWei: bigint;
unknownBreakdownCount: number;
byToken: Map<string, TokenAgg>;
byBridge: Map<PaymentBridgeKey, BridgeAgg>;
};
Expand Down Expand Up @@ -162,6 +179,8 @@ function emptyBridgeAgg(bridge: PaymentBridgeKey): BridgeAgg {
errorCount: 0,
totalMerchantWei: 0n,
totalFeeWei: 0n,
totalSaleWei: 0n,
totalNetworkFeeWei: 0n,
};
}

Expand Down Expand Up @@ -207,6 +226,19 @@ function aggregate(entries: LogEntry[]): {
const merchantWei = parseWei(e.merchantAmount);
const feeWei = parseWei(e.feeAmount);
const bridgeKey = normalizeBridge(e.bridge);
// v3: 売上総額 (gross) は saleAmount を優先し、無い旧 log は着金額で代替 (gas=customer の
// 大多数は両者一致するため GMV は概ね保たれる)。ネットワーク手数料相当額は非 circle の
// networkFeeEquivalent と circle の circlePaymasterNetUsdc を coalesce。
const saleWei =
e.saleAmount !== undefined ? parseWei(e.saleAmount) : merchantWei;
const netFeeWei =
e.networkFeeEquivalent !== undefined
? parseWei(e.networkFeeEquivalent)
: parseWei(e.circlePaymasterNetUsdc);
// feeBreakdownVersion を持たない旧 log は feeAmount が conflated (gas 混在) のため、
// OpenPay 利用手数料 total (totalFeeWei) には計上しない (内訳不明として別途カウント)。
const hasSeparatedBreakdown =
typeof e.feeBreakdownVersion === 'number' && e.feeBreakdownVersion >= 1;
// standard mode は merchant 送金 tx (flow=standard-merchant) と OpenPay 手数料
// 徴収 tx (flow=standard-fee) の 2 entry で 1 logical sale を構成する。
// - standard-merchant: 通常 entry と同じく count++ / GMV 計上
Expand All @@ -229,6 +261,9 @@ function aggregate(entries: LogEntry[]): {
errorCount: 0,
totalMerchantWei: 0n,
totalFeeWei: 0n,
totalSaleWei: 0n,
totalNetworkFeeWei: 0n,
unknownBreakdownCount: 0,
byToken: new Map(),
byBridge: new Map(),
};
Expand All @@ -244,6 +279,8 @@ function aggregate(entries: LogEntry[]): {
errorCount: 0,
totalMerchantWei: 0n,
totalFeeWei: 0n,
totalSaleWei: 0n,
totalNetworkFeeWei: 0n,
};
chain.byToken.set(tokenAddress, token);
}
Expand Down Expand Up @@ -294,15 +331,27 @@ function aggregate(entries: LogEntry[]): {
provider.circleNetUsdcReported += net;
}
}
// GMV は success のみ計上 (reverted は資金移動なし、error は submit 失敗)
// GMV は success のみ計上 (reverted は資金移動なし、error は submit 失敗)。
// totalFeeWei (OpenPay 利用手数料) は分離記録済 log のみ計上し、内訳不明の旧 log は
// 除外する (conflated feeAmount が利用手数料収益として誤計上されるのを防ぐ)。
const serviceFeeWei = hasSeparatedBreakdown ? feeWei : 0n;
if (!hasSeparatedBreakdown) chain.unknownBreakdownCount++;
chain.totalMerchantWei += merchantWei;
chain.totalFeeWei += feeWei;
chain.totalFeeWei += serviceFeeWei;
chain.totalSaleWei += saleWei;
chain.totalNetworkFeeWei += netFeeWei;
token.totalMerchantWei += merchantWei;
token.totalFeeWei += feeWei;
token.totalFeeWei += serviceFeeWei;
token.totalSaleWei += saleWei;
token.totalNetworkFeeWei += netFeeWei;
chainBridge.totalMerchantWei += merchantWei;
chainBridge.totalFeeWei += feeWei;
chainBridge.totalFeeWei += serviceFeeWei;
chainBridge.totalSaleWei += saleWei;
chainBridge.totalNetworkFeeWei += netFeeWei;
globalBridge.totalMerchantWei += merchantWei;
globalBridge.totalFeeWei += feeWei;
globalBridge.totalFeeWei += serviceFeeWei;
globalBridge.totalSaleWei += saleWei;
globalBridge.totalNetworkFeeWei += netFeeWei;
} else if (result === 'reverted') {
chain.revertedCount++;
token.revertedCount++;
Expand Down Expand Up @@ -353,6 +402,8 @@ function serializeBridges(bridges: BridgeAgg[]) {
errorCount: b.errorCount,
totalMerchantWei: b.totalMerchantWei.toString(),
totalFeeWei: b.totalFeeWei.toString(),
totalSaleWei: b.totalSaleWei.toString(),
totalNetworkFeeWei: b.totalNetworkFeeWei.toString(),
}));
}

Expand All @@ -379,6 +430,9 @@ function serialize(chains: ChainAgg[]) {
errorCount: c.errorCount,
totalMerchantWei: c.totalMerchantWei.toString(),
totalFeeWei: c.totalFeeWei.toString(),
totalSaleWei: c.totalSaleWei.toString(),
totalNetworkFeeWei: c.totalNetworkFeeWei.toString(),
unknownBreakdownCount: c.unknownBreakdownCount,
byToken: Array.from(c.byToken.values())
.sort((a, b) => b.successCount - a.successCount)
.map((t) => ({
Expand All @@ -388,6 +442,8 @@ function serialize(chains: ChainAgg[]) {
errorCount: t.errorCount,
totalMerchantWei: t.totalMerchantWei.toString(),
totalFeeWei: t.totalFeeWei.toString(),
totalSaleWei: t.totalSaleWei.toString(),
totalNetworkFeeWei: t.totalNetworkFeeWei.toString(),
})),
// chain ごとの bridge breakdown。固定順 (direct → gateway → cctp-v2 → unknown)。
byBridge: serializeBridges(
Expand Down
15 changes: 14 additions & 1 deletion components/CheckoutForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {
// Circle 経路は顧客が permit で USDC gas を Circle paymaster に支払うため、sponsorship
// 形式の gas 立替 reimbursement は加算しない (testnet sponsorship 倒し時の二重徴収防止)。
const gasReimbursement = isSponsorship && !isCircle ? (gasAmount ?? 0n) : 0n;

// 記録用ネットワーク手数料相当額 (会計分離・on-chain transfer とは別)。非 circle の
// gasless 経路は gas 見積を計上 (JPYC sponsorship=立替回収 / USDC erc20=paymaster 徴収分)。
// circle は receipt 由来の circlePaymasterNetUsdc を使うため null、standard は null。
const networkFeeEquivalent =
!isStandard && !isCircle ? (gasAmount ?? 0n) : null;
const fmt = (wei: bigint) => formatTokenAmount(wei, deployment);

// ネイティブガストークン symbol を viem chain 経由で取得 (chain-aware)。
Expand Down Expand Up @@ -306,6 +312,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {
customer: address,
feeReceiver: env.feeReceiver,
feeAmount: breakdown.feeAmount,
saleAmount: totalWei,
networkFeeEquivalent,
storeName: '',
note: params.description ?? params.orderId ?? '',
}),
Expand All @@ -321,6 +329,8 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {
isStandard,
breakdown.merchantReceives,
breakdown.feeAmount,
totalWei,
networkFeeEquivalent,
address,
],
);
Expand Down Expand Up @@ -370,7 +380,10 @@ export function CheckoutForm({ params }: { params: CheckoutParams }) {
merchant: params.to,
merchantAmount: breakdown.merchantReceives,
feeReceiver: env.feeReceiver,
feeAmount: breakdown.feeAmount + gasReimbursement,
feeAmount: breakdown.feeAmount,
gasReimbursement,
saleAmount: totalWei,
networkFeeEquivalent: networkFeeEquivalent ?? undefined,
circlePermitAmount,
});
}
Expand Down
88 changes: 55 additions & 33 deletions components/HistoryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { formatUnits } from 'viem';
import { addressExplorerUrl, txExplorerUrl } from '@/lib/chains';
import {
formatHistoryTimestamp,
hasSeparatedBreakdown,
HISTORY_ASSET_DECIMALS,
HISTORY_ASSET_DISPLAY,
networkFeeEquivalentOf,
type CircleVerification,
type HistoryEntry,
} from '@/lib/history';
Expand Down Expand Up @@ -78,6 +80,31 @@ export function HistoryRow({
: undefined;
const merchantUrl = addressExplorerUrl(entry.chainId, entry.merchant);

// v3 fee/gas 分離表示。
// - OpenPay 利用手数料 (サービス料): native v3 で > 0 のときだけ行を出す (alpha / 将来とも
// 0 = 非表示。決済額非連動・収益化は周辺機能で決済フロー非経由)。
// - ネットワーク手数料相当額: 全経路横断の統一項目 (networkFeeEquivalentOf で coalesce)。
// circle は検証ステータス badge を併記。
// - legacy(migrated v2): 分離記録が無く feeAmount に gas が混在しうるため、gasless かつ
// feeAmount > 0 を旧 band-aid (PR #23) でネットワーク手数料相当額として表示する。
const separated = hasSeparatedBreakdown(entry);
const netFeeRaw = separated
? networkFeeEquivalentOf(entry)
: entry.payMode === 'gasless'
? entry.feeAmount
: null;
const isNonZero = (v: string | null): boolean =>
v != null && v !== '0' && v !== '';
const isCircle = entry.provider === 'circle';
// circle は net 不明 (unreconciled) でも検証ステータスを可視化するため行を出す。
const showNetFee =
isNonZero(netFeeRaw) || (isCircle && entry.circleVerification != null);
const showServiceFee = separated && isNonZero(entry.feeAmount);
// 売上総額は着金額 (merchantAmount) と異なるとき (gas=merchant / split 等) のみ明示。
// 一致時は上部の大きな金額が兼ねる。
const showGrossSale =
entry.saleAmount != null && entry.saleAmount !== entry.merchantAmount;

function handleRemove() {
if (!window.confirm(t('removeRowConfirm'))) return;
onRemove(entry.id);
Expand Down Expand Up @@ -144,39 +171,34 @@ export function HistoryRow({
<dt className="text-slate-400">{t('columnNetwork')}</dt>
<dd>{entry.chainSlug}</dd>
</div>
<div>
{/* alpha 中は OpenPay 利用手数料 0% のため、gasless で feeAmount>0 のときは
それは運営が立替えた gas の実費回収 (ネットワーク手数料相当・JPYC sponsorship)
であり利用手数料ではない。よってその場合は「ネットワーク手数料」と表示する。
(恒久対応: feeAmount に service fee と gas reimbursement を混在させず分離記録する。
将来 service fee>0 にする際は必須 — 下記 CSV/stats も同根。) */}
<dt className="text-slate-400">
{entry.payMode === 'gasless' &&
entry.feeAmount != null &&
entry.feeAmount !== '0' &&
entry.feeAmount !== ''
? t('columnNetworkFee')
: t('columnFee')}
</dt>
<dd>{fmt(entry.feeAmount, entry.asset)}</dd>
</div>
{entry.provider === 'circle' &&
(entry.circlePaymasterNetUsdc || entry.circleVerification) && (
<div>
<dt className="text-slate-400">{t('columnCircleGas')}</dt>
<dd className="flex flex-wrap items-center gap-1.5">
<span>{fmt(entry.circlePaymasterNetUsdc, 'usdc')}</span>
{entry.circleVerification && (
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset ${CIRCLE_VERIF_BADGE_CLASS[entry.circleVerification]}`}
title={t(CIRCLE_VERIF_HELP_KEY[entry.circleVerification])}
>
{t(CIRCLE_VERIF_I18N_KEY[entry.circleVerification])}
</span>
)}
</dd>
</div>
)}
{showGrossSale && (
<div>
<dt className="text-slate-400">{t('columnSaleAmount')}</dt>
<dd>{fmt(entry.saleAmount, entry.asset)}</dd>
</div>
)}
{showServiceFee && (
<div>
<dt className="text-slate-400">{t('columnFee')}</dt>
<dd>{fmt(entry.feeAmount, entry.asset)}</dd>
</div>
)}
{showNetFee && (
<div>
<dt className="text-slate-400">{t('columnNetworkFee')}</dt>
<dd className="flex flex-wrap items-center gap-1.5">
<span>{fmt(netFeeRaw, entry.asset)}</span>
{isCircle && entry.circleVerification && (
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-semibold ring-1 ring-inset ${CIRCLE_VERIF_BADGE_CLASS[entry.circleVerification]}`}
title={t(CIRCLE_VERIF_HELP_KEY[entry.circleVerification])}
>
{t(CIRCLE_VERIF_I18N_KEY[entry.circleVerification])}
</span>
)}
</dd>
</div>
)}
{entry.note && (
<div className="sm:col-span-2">
<dt className="text-slate-400">{t('columnNote')}</dt>
Expand Down
Loading
Loading