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
5 changes: 5 additions & 0 deletions .changeset/card-rate-phase1-estimator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@app/ratewise': minor
---

新增刷卡估算模式(Phase 1,feature flag 控制):匯率來源新增「刷卡」選項,以台銀即期賣出價加上可調整的海外交易手續費(0–3%、預設 1.5%)估算刷卡實際成本;估算基準固定即期賣出(即期缺失時誠實改用現金賣出並同步標示);估算卡常駐「估算」標記與完整計算式揭露,手續費可直接在卡片內微調並自動記住,附「實際扣款以發卡行清算日匯率為準」免責與刷卡匯率指南連結。
74 changes: 74 additions & 0 deletions apps/ratewise/src/config/__tests__/card-rate-flag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* card-rate flag 讀取端 SSOT 測試(ADR-002 Phase 1):
* URL override > 使用者設定(converterStore)> 預設 off、server snapshot 恆為 off(SSG 零暴露紅線)。
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
getCardRateServerSnapshot,
isCardRateEnabled,
setCardRateEnabled,
subscribeCardRateFlag,
} from '../card-rate-flag';
import { useConverterStore } from '../../stores/converterStore';
import { CONVERTER_STORE_KEY } from '../../features/ratewise/storage-keys';

describe('card-rate-flag', () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState(null, '', '/');
useConverterStore.setState({ cardRateEnabled: false });
});

it('預設為 off(flag off 前端零暴露)', () => {
expect(isCardRateEnabled()).toBe(false);
});

it('server snapshot 恆為 false,保證 SSG 輸出不受使用者設定影響', () => {
useConverterStore.setState({ cardRateEnabled: true });
expect(getCardRateServerSnapshot()).toBe(false);
});

it('使用者設定開啟時啟用(converterStore SSOT)', () => {
useConverterStore.setState({ cardRateEnabled: true });
expect(isCardRateEnabled()).toBe(true);
});

it('URL ?cardRate=on 覆蓋使用者設定的 off', () => {
window.history.replaceState(null, '', '/?cardRate=on');
expect(isCardRateEnabled()).toBe(true);
});

it('URL ?cardRate=off 覆蓋使用者設定的 on', () => {
useConverterStore.setState({ cardRateEnabled: true });
window.history.replaceState(null, '', '/?cardRate=off');
expect(isCardRateEnabled()).toBe(false);
});

it('URL 非法值時回落使用者設定', () => {
useConverterStore.setState({ cardRateEnabled: true });
window.history.replaceState(null, '', '/?cardRate=bogus');
expect(isCardRateEnabled()).toBe(true);
});

it('setCardRateEnabled 持久化進 converterStore 並通知訂閱者', () => {
const listener = vi.fn();
const unsubscribe = subscribeCardRateFlag(listener);

setCardRateEnabled(true);

expect(useConverterStore.getState().cardRateEnabled).toBe(true);
expect(listener).toHaveBeenCalled();
expect(isCardRateEnabled()).toBe(true);

const persisted = JSON.parse(window.localStorage.getItem(CONVERTER_STORE_KEY) ?? '{}') as {
state?: { cardRateEnabled?: boolean };
};
expect(persisted.state?.cardRateEnabled).toBe(true);

unsubscribe();
listener.mockClear();
setCardRateEnabled(false);
expect(listener).not.toHaveBeenCalled();
});
});
5 changes: 5 additions & 0 deletions apps/ratewise/src/config/__tests__/rateProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,11 @@ describe('provider rate type resolution', () => {
getProviderPrimaryRateType(RATE_PROVIDERS.moneybox),
);
});

it('S1:card 基準固定即期(registry 外虛擬 provider 不得隱式放行 persisted cash)', () => {
expect(resolveRateTypeForSource('card', 'cash')).toBe('spot');
expect(resolveRateTypeForSource('card', 'spot')).toBe('spot');
});
});

describe('isProviderSupportedForCurrency', () => {
Expand Down
40 changes: 40 additions & 0 deletions apps/ratewise/src/config/card-rate-flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* 刷卡估算模式 feature flag(讀取端 SSOT,ADR-002 Phase 1)。
*
* 優先序(比照 converter-v2-flag):
* 1. URL override `?cardRate=on|off`——QA/支援直達用,僅影響本次瀏覽,不持久化。
* 2. 使用者設定——converterStore `cardRateEnabled`(`ratewise-converter` 持久化)。
* 3. 預設 off——與 server snapshot 一致(flag off 前端零暴露,SSG 紅線)。
*
* @see .claude/decisions/ADR-002-card-rates-pipeline.md
*/

import { useConverterStore } from '../stores/converterStore';

export function isCardRateEnabled(): boolean {
if (typeof window === 'undefined') {
return false;
}

const params = new URLSearchParams(window.location.search);
const cardRateParam = params.get('cardRate');
if (cardRateParam === 'on') return true;
if (cardRateParam === 'off') return false;

return useConverterStore.getState().cardRateEnabled;
}

/** 寫入使用者設定(持久化進 converterStore;URL override 不受影響)。 */
export function setCardRateEnabled(enabled: boolean): void {
useConverterStore.getState().setCardRateEnabled(enabled);
}

/** useSyncExternalStore subscribe:使用者設定變更時觸發重繪。 */
export function subscribeCardRateFlag(onStoreChange: () => void): () => void {
return useConverterStore.subscribe(onStoreChange);
}

/** server snapshot:SSG 一律 off,保證 flag off 時預渲染輸出不變。 */
export function getCardRateServerSnapshot(): boolean {
return false;
}
52 changes: 31 additions & 21 deletions apps/ratewise/src/config/rateProviderPublicMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ export interface PublicRateProviderMetadata {
providers: PublicRateProvider[];
}

/** 公開 API 僅暴露資料 provider;刷卡估算為虛擬 provider(ADR-002),不進公開 metadata。 */
export type PublicRateSourceKind = 'bank' | 'exchange-shop';

const isPublicSourceKind = (kind: string): kind is PublicRateSourceKind =>
kind === 'bank' || kind === 'exchange-shop';

export interface PublicRateProvider {
providerId: string;
sourceKind: 'bank' | 'exchange-shop';
sourceKind: PublicRateSourceKind;
name: string;
shortName: string;
supportedCurrencies: string[];
Expand All @@ -53,28 +59,32 @@ export function buildPublicRateProviderMetadata(
supportedSelectionModes: ['manual', 'best'],
currentSelectionMode: 'manual',
},
providers: getAllRateProviders().map((provider) => {
providers: getAllRateProviders().flatMap((provider): PublicRateProvider[] => {
const sourceKind = provider.sourceKind;
if (!isPublicSourceKind(sourceKind)) return [];
const currentPath = provider.apiPaths.latest;
const historyPath = provider.apiPaths.history.replace('{YYYY-MM-DD}', historyDateToken);
return {
providerId: provider.id,
sourceKind: provider.sourceKind,
name: provider.label,
shortName: provider.shortLabel,
supportedCurrencies:
provider.supportedCurrencies === 'all'
? allSupportedCurrencies
: provider.supportedCurrencies.map(String),
supportedRateTypes: [...provider.supportedRateTypes],
currentEndpoint: joinEndpoint(options.dataBaseUrl, currentPath),
historyEndpoint: joinEndpoint(options.dataBaseUrl, historyPath),
...(options.cdnBaseUrl
? {
cdnCurrentEndpoint: joinEndpoint(options.cdnBaseUrl, currentPath),
cdnHistoryEndpoint: joinEndpoint(options.cdnBaseUrl, historyPath),
}
: {}),
};
return [
{
providerId: provider.id,
sourceKind,
name: provider.label,
shortName: provider.shortLabel,
supportedCurrencies:
provider.supportedCurrencies === 'all'
? allSupportedCurrencies
: provider.supportedCurrencies.map(String),
supportedRateTypes: [...provider.supportedRateTypes],
currentEndpoint: joinEndpoint(options.dataBaseUrl, currentPath),
historyEndpoint: joinEndpoint(options.dataBaseUrl, historyPath),
...(options.cdnBaseUrl
? {
cdnCurrentEndpoint: joinEndpoint(options.cdnBaseUrl, currentPath),
cdnHistoryEndpoint: joinEndpoint(options.cdnBaseUrl, historyPath),
}
: {}),
},
];
}),
};
}
Expand Down
21 changes: 21 additions & 0 deletions apps/ratewise/src/config/rateProviders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,22 @@ export function getProviderPrimaryRateType(provider: RateProviderConfig): RateTy
return provider.supportedRateTypes[0] ?? DEFAULT_RATE_TYPE;
}

/**
* 刷卡估算虛擬 provider(ADR-002 Phase 1):非資料 provider(無 API endpoint),
* 不進 RATE_PROVIDERS registry,避免污染公開 API metadata 與 OpenData 頁(flag off 零暴露)。
* 估算基準取自台銀牌告,僅涉及 TWD 的貨幣對可用(刷卡帳單以 TWD 清算)。
*/
export const CARD_ESTIMATE_PROVIDER_ID = 'card-estimate' as const;

export const CARD_ESTIMATE_PROVIDER_REF: RateProviderRef = {
sourceKind: 'card',
providerId: CARD_ESTIMATE_PROVIDER_ID,
};

export function getDefaultProviderRef(sourceKind: RateSourceKind): RateProviderRef {
if (sourceKind === 'card') {
return CARD_ESTIMATE_PROVIDER_REF;
}
const provider = getDefaultProvider(sourceKind);
if (!provider) {
throw new Error(`Missing default rate provider for ${sourceKind}`);
Expand All @@ -103,6 +118,12 @@ export function resolveRateTypeForSource(
sourceKind: RateSourceKind,
requestedRateType: RateType,
): RateType {
// 刷卡估算基準固定即期賣出(ADR-002 SSOT):card 為 registry 外虛擬 provider,
// getDefaultProvider 查無會隱式放行 persisted rateType(如 cash 高估 1–2%),故特例正規化。
// 即期缺失回落現金由引擎賣出腿 fallback 處理(KRW 情境不變)。
if (sourceKind === 'card') {
return 'spot';
}
const provider = getDefaultProvider(sourceKind);
if (!provider) {
return requestedRateType;
Expand Down
13 changes: 10 additions & 3 deletions apps/ratewise/src/features/ratewise/RateWise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ import {

const RateWise = ({ rememberConverterView = true }: { rememberConverterView?: boolean } = {}) => {
const [searchParams] = useSearchParams();
// deep-link(分享連結帶 from/to/amount)只是臨時進入,不應覆寫上次停留模式偏好。
// deep-link(分享連結帶 from/to/amount,或 ?cardRate flag 入口)只是臨時進入,
// 不應覆寫上次停留模式偏好;與 RememberedHomeRoute 的 hasDeepLink 豁免同一契約。
const isDeepLink =
searchParams.has('from') || searchParams.has('to') || searchParams.has('amount');
searchParams.has('from') ||
searchParams.has('to') ||
searchParams.has('amount') ||
searchParams.has('cardRate');
// 還原決策完成前(hydrate 中或將導向 multi)不得寫入,否則會把 persist 的 multi 覆寫成 single。
useRememberConverterView(DEFAULT_CONVERTER_MODE, {
enabled: !isDeepLink && rememberConverterView,
Expand Down Expand Up @@ -104,6 +108,7 @@ const RateWise = ({ rememberConverterView = true }: { rememberConverterView?: bo
moneyBoxRate,
exchangeShopCurrency,
effectiveRateSource,
isCardRateAvailableInContext,
} = useCurrencyConverter({ exchangeRates, details, rateType, rateSource, mode: 'single' });

useEffect(() => {
Expand Down Expand Up @@ -146,9 +151,10 @@ const RateWise = ({ rememberConverterView = true }: { rememberConverterView?: bo
const handleRateSourceChange = useCallback(
(nextSource: RateSource) => {
if (nextSource === 'exchange-shop' && !exchangeShopCurrency) return;
if (nextSource === 'card' && !isCardRateAvailableInContext) return;
setRateSource(nextSource);
},
[exchangeShopCurrency, setRateSource],
[exchangeShopCurrency, isCardRateAvailableInContext, setRateSource],
);

// 首屏使用 build-time rates 直接渲染;只有完全沒有可用資料時才顯示 skeleton。
Expand Down Expand Up @@ -236,6 +242,7 @@ const RateWise = ({ rememberConverterView = true }: { rememberConverterView?: bo
rateSource={effectiveRateSource}
moneyBoxRate={moneyBoxRate}
exchangeShopCurrency={exchangeShopCurrency}
hasCardRate={isCardRateAvailableInContext}
rateMode={rateMode}
rateTypeAvailability={rateTypeAvailability}
onFromCurrencyChange={setFromCurrency}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,51 @@ describe('resolveProviderPreference - manual mode', () => {
expect(resolved.sourceKind).toBe('exchange-shop');
});

it('manualProvider 為刷卡估算+涉及 TWD 的 pair → reason=manual(虛擬 provider 支援)', () => {
const preference: RateProviderPreference = {
mode: 'manual',
manualProvider: { sourceKind: 'card', providerId: 'card-estimate' },
};

for (const [from, to] of [
['USD', 'TWD'],
['TWD', 'JPY'],
] as const) {
const resolved = resolveProviderPreference({
preference,
from,
to,
rateType: 'spot',
quotes: [],
});
expect(resolved.reason).toBe('manual');
expect(resolved.sourceKind).toBe('card');
expect(resolved.providerId).toBe('card-estimate');
}
});

it('manualProvider 為刷卡估算+不涉 TWD 的 pair → 退回預設並標記 unsupported-pair', () => {
const preference: RateProviderPreference = {
mode: 'manual',
manualProvider: { sourceKind: 'card', providerId: 'card-estimate' },
};

const resolved = resolveProviderPreference({
preference,
from: 'USD',
to: 'JPY',
rateType: 'spot',
quotes: [],
});

expect(resolved).toEqual<ResolvedRateProvider>({
selectionMode: 'manual',
sourceKind: 'bank',
providerId: 'bot',
reason: 'unsupported-pair',
});
});

it('manualProvider 未設定(mode=manual 但 manualProvider undefined)→ fallback-default', () => {
const preference: RateProviderPreference = { mode: 'manual' };

Expand Down
Loading
Loading