mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
feat: integrate cloud credits store flow
Replace ZPAN Store Wallet semantics with Credits-only APIs and UI. Proxy Cloud credits through credit-account routes, send unitless gift-card credits, and block metered downloads on insufficient Cloud credits before presign while rolling back local counters. Verification waiver: E2E fails only because the hosted Cloud target rejects the merged Credits gift-card payload; AK follow-up kxel47x6x6l3 tracks that target gap.
This commit is contained in:
@@ -108,7 +108,7 @@ Manual preview evidence required before merge:
|
||||
|
||||
1. Open admin quota-store settings, enable the store, then create an active monthly storage package and confirm Cloud sync status.
|
||||
- Screenshot: `/tmp/zpan-metered-storage-preview/admin-package-store.png`
|
||||
2. Open **Storage**, confirm the quota panel shows base storage, Cloud storage entitlement, included traffic, current-period traffic, and wallet balance.
|
||||
2. Open **Storage**, confirm the quota panel shows base storage, Cloud storage entitlement, included traffic, current-period traffic, and credit balance.
|
||||
- Screenshot: `/tmp/zpan-metered-storage-preview/storage-quota-panel.png`
|
||||
3. Open the terminal-user Store from the quota meter without a Cloud account, select a personal or team target org, and confirm active packages render.
|
||||
- Screenshot: `/tmp/zpan-metered-storage-preview/user-store-packages.png`
|
||||
|
||||
+15
-19
@@ -49,7 +49,7 @@ test.describe
|
||||
await unbindCurrentCloudBinding()
|
||||
})
|
||||
|
||||
test('@desktop covers pairing, admin store setup, gift-card wallet redemption, and wallet checkout', async ({
|
||||
test('@desktop covers pairing, admin store setup, gift-card credit redemption, and checkout', async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
@@ -69,9 +69,9 @@ test.describe
|
||||
await expect(page.getByRole('heading', { name: 'Storage' })).toBeVisible({ timeout: 20_000 })
|
||||
await expectStorefrontProductVisibleInApi(page, packageName)
|
||||
|
||||
const walletBefore = await getWalletBalance(page)
|
||||
const creditsBefore = await getCreditBalance(page)
|
||||
await redeemGiftCard(page, giftCard.code)
|
||||
await expect.poll(() => getWalletBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(walletBefore + 200)
|
||||
await expect.poll(() => getCreditBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(creditsBefore + 200)
|
||||
|
||||
const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin))
|
||||
if (!hasPublicCallbackUrl) {
|
||||
@@ -132,11 +132,11 @@ test.describe
|
||||
await expect(userPage.getByRole('heading', { name: 'Storage' })).toBeVisible({ timeout: 20_000 })
|
||||
await expectStorefrontProductVisibleInApi(userPage, packageName)
|
||||
|
||||
const walletBefore = await getWalletBalance(userPage)
|
||||
const creditsBefore = await getCreditBalance(userPage)
|
||||
await redeemGiftCard(userPage, giftCard.code)
|
||||
await expect
|
||||
.poll(() => getWalletBalance(userPage), { timeout: 20_000 })
|
||||
.toBeGreaterThanOrEqual(walletBefore + 200)
|
||||
.poll(() => getCreditBalance(userPage), { timeout: 20_000 })
|
||||
.toBeGreaterThanOrEqual(creditsBefore + 200)
|
||||
} finally {
|
||||
await userContext.close()
|
||||
}
|
||||
@@ -263,8 +263,7 @@ async function createOneTimePackage(page: Page, name: string) {
|
||||
|
||||
async function createGiftCard(page: Page) {
|
||||
const cards = await postJson<CloudGiftCard[]>(page, '/api/admin/store/gift-cards', {
|
||||
amount: 200,
|
||||
currency: 'usd',
|
||||
credits: 200,
|
||||
count: 1,
|
||||
})
|
||||
expect(cards.length).toBe(1)
|
||||
@@ -301,7 +300,7 @@ async function createGiftCardThroughUi(page: Page) {
|
||||
await page.getByRole('tab', { name: 'Gift Cards' }).click()
|
||||
await page.getByRole('button', { name: 'Generate gift cards' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Generate gift cards' })
|
||||
await dialog.getByLabel('Amount').fill('3')
|
||||
await dialog.getByLabel('Credits').fill('3')
|
||||
|
||||
const response = page.waitForResponse(
|
||||
(item) => item.url().includes('/api/admin/store/gift-cards') && item.request().method() === 'POST',
|
||||
@@ -341,13 +340,13 @@ async function expectAdminGiftCardVisibleInApi(page: Page, code: string) {
|
||||
}
|
||||
|
||||
async function redeemGiftCard(page: Page, code: string) {
|
||||
await page.getByRole('button', { name: 'Wallet' }).click()
|
||||
const walletDialog = page.getByRole('dialog', { name: 'Wallet' })
|
||||
await walletDialog.getByRole('button', { name: 'Redeem gift card' }).click()
|
||||
await page.getByRole('button', { name: 'Credits' }).click()
|
||||
const creditsDialog = page.getByRole('dialog', { name: 'Credits' })
|
||||
await creditsDialog.getByRole('button', { name: 'Redeem gift card' }).click()
|
||||
const redeemDialog = page.getByRole('dialog', { name: 'Redeem gift card' })
|
||||
await redeemDialog.getByLabel('Gift card code').fill(code)
|
||||
const redeemResponse = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/store/gift-cards/redeem') && response.request().method() === 'POST',
|
||||
(response) => response.url().includes('/api/store/credits/redemptions') && response.request().method() === 'POST',
|
||||
)
|
||||
await redeemDialog.getByRole('button', { name: 'Redeem' }).click()
|
||||
expect((await redeemResponse).status()).toBe(200)
|
||||
@@ -355,12 +354,9 @@ async function redeemGiftCard(page: Page, code: string) {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
async function getWalletBalance(page: Page) {
|
||||
const wallet = await getJson<{ items: Array<{ availableAmount: number; currency: string }> }>(
|
||||
page,
|
||||
'/api/store/wallet',
|
||||
)
|
||||
return wallet.items.find((balance) => balance.currency === 'usd')?.availableAmount ?? 0
|
||||
async function getCreditBalance(page: Page) {
|
||||
const credits = await getJson<{ balance: number }>(page, '/api/store/credits')
|
||||
return credits.balance
|
||||
}
|
||||
|
||||
async function expectStorefrontProductVisibleInApi(page: Page, packageName: string) {
|
||||
|
||||
@@ -125,8 +125,7 @@ describe('quota store helper schemas', () => {
|
||||
campaignId: null,
|
||||
code: null,
|
||||
codeLast4: 'GED1',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
credits: 2500,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
@@ -148,8 +147,7 @@ describe('quota store helper schemas', () => {
|
||||
campaignId: null,
|
||||
code: null,
|
||||
codeLast4: 'GED1',
|
||||
amount: 2500,
|
||||
currency: 'usd',
|
||||
credits: 2500,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
@@ -172,8 +170,7 @@ describe('quota store helper schemas', () => {
|
||||
campaignId: null,
|
||||
code: 'ZS-CREATED-1',
|
||||
codeLast4: 'TED1',
|
||||
amount: 500,
|
||||
currency: 'usd',
|
||||
credits: 500,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
createdAt: '2026-05-07T00:00:00.000Z',
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
billingPortalSessionResponseSchema,
|
||||
commerceProductSchema,
|
||||
createCloudClient,
|
||||
giftCardListResponseSchema,
|
||||
paymentCreateResponseSchema,
|
||||
productListResponseSchema,
|
||||
storeGiftCardSchema,
|
||||
} from 'zpan-cloud-sdk'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import type { Env } from '../middleware/platform'
|
||||
@@ -34,9 +32,28 @@ export const cloudOrdersQuerySchema = z.object({
|
||||
})
|
||||
export const cloudStoreOrdersQuerySchema = cloudOrdersQuerySchema
|
||||
|
||||
export const cloudGiftCardSchema = storeGiftCardSchema
|
||||
export const cloudGiftCardsResponseSchema = giftCardListResponseSchema
|
||||
export const cloudGiftCardListSchema = z.array(storeGiftCardSchema)
|
||||
export const cloudGiftCardSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
storeId: z.string().min(1),
|
||||
campaignId: z.string().nullable(),
|
||||
code: z.string().nullable(),
|
||||
codeLast4: z.string().min(1),
|
||||
credits: z.number().int().positive(),
|
||||
status: giftCardStatusSchema,
|
||||
expiresAt: z.string().nullable(),
|
||||
createdAt: z.string().min(1),
|
||||
updatedAt: z.string().min(1),
|
||||
disabledAt: z.string().nullable(),
|
||||
revokedAt: z.string().nullable(),
|
||||
createdByAdmin: z.string().min(1),
|
||||
})
|
||||
export const cloudGiftCardsResponseSchema = z.object({
|
||||
items: z.array(cloudGiftCardSchema),
|
||||
total: z.number().int(),
|
||||
limit: z.number().int().optional(),
|
||||
offset: z.number().int().optional(),
|
||||
})
|
||||
export const cloudGiftCardListSchema = z.array(cloudGiftCardSchema)
|
||||
export const cloudGiftCardCreateResponseSchema = z
|
||||
.union([cloudGiftCardListSchema, cloudGiftCardsResponseSchema])
|
||||
.transform((response) => (Array.isArray(response) ? response : response.items))
|
||||
|
||||
@@ -22,8 +22,7 @@ const zpanCloudGiftCardResponseFixture: CloudGiftCard = {
|
||||
campaignId: null,
|
||||
code: null,
|
||||
codeLast4: '0001',
|
||||
amount: 1000,
|
||||
currency: 'usd',
|
||||
credits: 1000,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
@@ -147,9 +146,9 @@ beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (url, init) => {
|
||||
if (String(url).includes('/api/stores/') && String(url).includes('/wallets/')) {
|
||||
if (String(url).includes('/api/stores/') && String(url).includes('/credit-accounts/')) {
|
||||
if (init?.method === 'GET') {
|
||||
if (String(url).includes('/transactions')) {
|
||||
if (String(url).includes('/ledger-entries')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -157,9 +156,10 @@ beforeEach(() => {
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
creditAccountId: 'credit-account-1',
|
||||
creditBucketId: 'credit-bucket-1',
|
||||
storeId: 'store-test-binding',
|
||||
customerId: 'org-placeholder',
|
||||
currency: 'usd',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
@@ -167,7 +167,6 @@ beforeEach(() => {
|
||||
sourceId: 'gift-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
stripeCustomerBalanceTransactionId: null,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
@@ -181,21 +180,7 @@ beforeEach(() => {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{
|
||||
id: 'wallet-1',
|
||||
storeId: 'store-test-binding',
|
||||
customerId: 'org-placeholder',
|
||||
currency: 'usd',
|
||||
availableAmount: 1250,
|
||||
pendingAmount: 0,
|
||||
stripeCustomerId: null,
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
balance: 1250,
|
||||
}),
|
||||
} as Response
|
||||
}
|
||||
@@ -203,8 +188,7 @@ beforeEach(() => {
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({
|
||||
redeemedAmount: 1000,
|
||||
currency: 'usd',
|
||||
redeemedCredits: 1000,
|
||||
entries: [],
|
||||
failures: [],
|
||||
}),
|
||||
@@ -222,12 +206,12 @@ beforeEach(() => {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
items: [cloudGiftCard({ code: 'ZS-LIST-1', codeLast4: 'ST-1', amount: 1024 })],
|
||||
items: [cloudGiftCard({ code: 'ZS-LIST-1', codeLast4: 'ST-1', credits: 1024 })],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
data: {
|
||||
items: [cloudGiftCard({ code: 'ZS-LIST-1', codeLast4: 'ST-1', amount: 1024 })],
|
||||
items: [cloudGiftCard({ code: 'ZS-LIST-1', codeLast4: 'ST-1', credits: 1024 })],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
@@ -235,7 +219,7 @@ beforeEach(() => {
|
||||
}),
|
||||
} as Response
|
||||
}
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { amount?: number; count?: number }
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { credits?: number; count?: number }
|
||||
return {
|
||||
ok: true,
|
||||
status: 201,
|
||||
@@ -244,7 +228,7 @@ beforeEach(() => {
|
||||
cloudGiftCard({
|
||||
code: `ZS-GEN-${index + 1}`,
|
||||
codeLast4: `GEN${index + 1}`,
|
||||
amount: body.amount ?? 1024,
|
||||
credits: body.credits ?? 1024,
|
||||
status: 'active',
|
||||
}),
|
||||
),
|
||||
@@ -423,8 +407,7 @@ describe('Quota Store API', () => {
|
||||
cloudGiftCard({
|
||||
code: 'ZS11-ACTV-0000-0001',
|
||||
codeLast4: '0001',
|
||||
amount: 2048,
|
||||
currency: 'usd',
|
||||
credits: 2048,
|
||||
}),
|
||||
],
|
||||
total: 1,
|
||||
@@ -439,8 +422,7 @@ describe('Quota Store API', () => {
|
||||
campaignId: null,
|
||||
code: 'ZS11-ACTV-0000-0001',
|
||||
codeLast4: '0001',
|
||||
amount: 2048,
|
||||
currency: 'usd',
|
||||
credits: 2048,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
@@ -1304,8 +1286,7 @@ describe('Quota Store API', () => {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: 4096,
|
||||
currency: 'usd',
|
||||
credits: 4096,
|
||||
expiresAt: '2099-06-01T00:00:00.000Z',
|
||||
count: 2,
|
||||
}),
|
||||
@@ -1315,13 +1296,13 @@ describe('Quota Store API', () => {
|
||||
|
||||
expect(generated.status).toBe(201)
|
||||
await expect(generated.json()).resolves.toMatchObject([
|
||||
{ code: 'ZS-GEN-1', amount: 4096, status: 'active' },
|
||||
{ code: 'ZS-GEN-2', amount: 4096, status: 'active' },
|
||||
{ code: 'ZS-GEN-1', credits: 4096, status: 'active' },
|
||||
{ code: 'ZS-GEN-2', credits: 4096, status: 'active' },
|
||||
])
|
||||
expect(listed.status).toBe(200)
|
||||
await expect(listed.json()).resolves.toMatchObject({
|
||||
total: 1,
|
||||
items: [{ code: 'ZS-LIST-1', amount: 1024 }],
|
||||
items: [{ code: 'ZS-LIST-1', credits: 1024 }],
|
||||
})
|
||||
expect(deleted.status).toBe(200)
|
||||
await expect(deleted.json()).resolves.toEqual({ code: 'ZS-GEN-1', deleted: true })
|
||||
@@ -1333,8 +1314,7 @@ describe('Quota Store API', () => {
|
||||
expect(String(generateUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards`)
|
||||
expect(requestHeader(generateInit, 'Authorization')).toBe(`Bearer ${REFRESH_TOKEN}`)
|
||||
expect(JSON.parse(generateInit.body as string)).toEqual({
|
||||
amount: 4096,
|
||||
currency: 'usd',
|
||||
credits: 4096,
|
||||
expiresAt: '2099-06-01T00:00:00.000Z',
|
||||
count: 2,
|
||||
})
|
||||
@@ -1381,8 +1361,7 @@ describe('Quota Store API', () => {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: 4096,
|
||||
currency: 'usd',
|
||||
credits: 4096,
|
||||
count: 1,
|
||||
}),
|
||||
})
|
||||
@@ -1576,7 +1555,7 @@ describe('Quota Store API', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('omits wallet credit when checking out recurring packages', async () => {
|
||||
it('omits credit discount fields when checking out recurring packages', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app, 'buyer@example.com')
|
||||
@@ -1606,7 +1585,6 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(checkout.status).toBe(200)
|
||||
expect(orderPayload()).not.toHaveProperty('walletCreditAmount')
|
||||
})
|
||||
|
||||
it('rejects recurring checkout when the workspace already has an active plan', async () => {
|
||||
@@ -1651,7 +1629,7 @@ describe('Quota Store API', () => {
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses wallet credit when checking out fixed-duration packages', async () => {
|
||||
it('creates fixed-duration package checkouts without credit discount fields', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app, 'buyer@example.com')
|
||||
@@ -1665,7 +1643,6 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
expect(checkout.status).toBe(200)
|
||||
expect(orderPayload()).not.toHaveProperty('walletCreditAmount')
|
||||
})
|
||||
|
||||
it('creates a subscription portal for the active workspace plan', async () => {
|
||||
@@ -1743,7 +1720,6 @@ describe('Quota Store API', () => {
|
||||
customerLabel: 'buyer@example.com',
|
||||
},
|
||||
})
|
||||
expect(orderBody).not.toHaveProperty('walletCreditAmount')
|
||||
const [paymentUrl, paymentInit] = calls.find(([url]) => String(url).includes('/payments'))!
|
||||
const paymentBody = JSON.parse(String(paymentInit.body))
|
||||
expect(String(paymentUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/orders/order-cloud-1/payments`)
|
||||
@@ -1774,73 +1750,61 @@ describe('Quota Store API', () => {
|
||||
expect(parsedOrdersUrl.searchParams.get('customerId')).toBe(orgId)
|
||||
})
|
||||
|
||||
it('proxies wallet balance and gift card redemption through wallet endpoints', async () => {
|
||||
it('proxies credit balance and gift card redemption through credit endpoints', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app, 'buyer@example.com')
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
|
||||
const wallet = await app.request('/api/store/wallet', { headers })
|
||||
const redeem = await app.request('/api/store/gift-cards/redeem', {
|
||||
const credits = await app.request('/api/store/credits', { headers })
|
||||
const redeem = await app.request('/api/store/credits/redemptions', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'ZS-1234-5678' }),
|
||||
})
|
||||
|
||||
expect(wallet.status).toBe(200)
|
||||
await expect(wallet.json()).resolves.toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'wallet-1',
|
||||
storeId: 'store-test-binding',
|
||||
customerId: 'org-placeholder',
|
||||
currency: 'usd',
|
||||
availableAmount: 1250,
|
||||
pendingAmount: 0,
|
||||
stripeCustomerId: null,
|
||||
updatedAt: '2026-05-06T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
expect(credits.status).toBe(200)
|
||||
await expect(credits.json()).resolves.toEqual({ balance: 1250 })
|
||||
expect(redeem.status).toBe(200)
|
||||
await expect(redeem.json()).resolves.toEqual({
|
||||
redeemedAmount: 1000,
|
||||
currency: 'usd',
|
||||
redeemedCredits: 1000,
|
||||
entries: [],
|
||||
failures: [],
|
||||
})
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]>
|
||||
const [walletUrl, walletInit] = calls.find(([url]) => String(url).includes(`/wallets/${orgId}/balances`))!
|
||||
const [redeemUrl, redeemInit] = calls.find(([url]) => String(url).includes(`/wallets/${orgId}/redemptions`))!
|
||||
expect(String(walletUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/wallets/${orgId}/balances`)
|
||||
expect(walletInit.method).toBe('GET')
|
||||
expect(String(redeemUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/wallets/${orgId}/redemptions`)
|
||||
const [creditsUrl, creditsInit] = calls.find(([url]) => String(url).includes(`/credit-accounts/${orgId}/balance`))!
|
||||
const [redeemUrl, redeemInit] = calls.find(([url]) =>
|
||||
String(url).includes(`/credit-accounts/${orgId}/redemptions`),
|
||||
)!
|
||||
expect(String(creditsUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/credit-accounts/${orgId}/balance`)
|
||||
expect(creditsInit.method).toBe('GET')
|
||||
expect(String(redeemUrl)).toBe(
|
||||
`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/credit-accounts/${orgId}/redemptions`,
|
||||
)
|
||||
expect(redeemInit.method).toBe('POST')
|
||||
expect(JSON.parse(String(redeemInit.body))).toEqual({ codes: ['ZS-1234-5678'] })
|
||||
})
|
||||
|
||||
it('proxies wallet transactions through wallet endpoints', async () => {
|
||||
it('proxies credit ledger entries through credit endpoints', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app, 'buyer@example.com')
|
||||
await seedSettings(app, headers)
|
||||
const orgId = await getFirstOrgId(db)
|
||||
|
||||
const transactions = await app.request('/api/store/wallet/transactions', { headers })
|
||||
const ledger = await app.request('/api/store/credits/ledger-entries', { headers })
|
||||
|
||||
expect(transactions.status).toBe(200)
|
||||
await expect(transactions.json()).resolves.toEqual({
|
||||
expect(ledger.status).toBe(200)
|
||||
await expect(ledger.json()).resolves.toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
creditAccountId: 'credit-account-1',
|
||||
creditBucketId: 'credit-bucket-1',
|
||||
storeId: 'store-test-binding',
|
||||
customerId: 'org-placeholder',
|
||||
currency: 'usd',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
@@ -1848,7 +1812,6 @@ describe('Quota Store API', () => {
|
||||
sourceId: 'gift-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
stripeCustomerBalanceTransactionId: null,
|
||||
createdAt: '2026-05-06T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
@@ -1858,13 +1821,13 @@ describe('Quota Store API', () => {
|
||||
})
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]>
|
||||
const [transactionsUrl, transactionsInit] = calls.find(([url]) =>
|
||||
String(url).includes(`/wallets/${orgId}/transactions`),
|
||||
const [ledgerUrl, ledgerInit] = calls.find(([url]) =>
|
||||
String(url).includes(`/credit-accounts/${orgId}/ledger-entries`),
|
||||
)!
|
||||
expect(String(transactionsUrl)).toBe(
|
||||
`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/wallets/${orgId}/transactions`,
|
||||
expect(String(ledgerUrl)).toBe(
|
||||
`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/credit-accounts/${orgId}/ledger-entries`,
|
||||
)
|
||||
expect(transactionsInit.method).toBe('GET')
|
||||
expect(ledgerInit.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('continues payment and cancels orders through Cloud', async () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Hono } from 'hono'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { requireFeature } from '../../middleware/require-feature'
|
||||
import { getCloudStoreSettings, upsertCloudStoreSettings } from '../../services/cloud-store'
|
||||
import { getCloudStoreBinding, getCloudStoreSettings, upsertCloudStoreSettings } from '../../services/cloud-store'
|
||||
import { requestBoundCloudJson } from '../../services/licensing-cloud'
|
||||
import {
|
||||
cloudGiftCardCreateResponseSchema,
|
||||
cloudGiftCardsResponseSchema,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
cloudPackageListResponseSchema,
|
||||
cloudPackageResponseSchema,
|
||||
getBoundCloudClient,
|
||||
getCloudBaseUrl,
|
||||
giftCardListQuerySchema,
|
||||
type RouteContext,
|
||||
unwrapCloudResponse,
|
||||
@@ -125,12 +127,7 @@ export const adminCloudStore = new Hono<Env>()
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/gift-cards', zValidator('json', createGiftCardInputSchema), async (c) => {
|
||||
const result = await cloudRequest(c, async ({ client, storeId }) =>
|
||||
unwrapCloudResponse(
|
||||
await client.stores[':storeId']['gift-cards'].$post({ param: { storeId }, json: c.req.valid('json') }),
|
||||
cloudGiftCardCreateResponseSchema,
|
||||
),
|
||||
)
|
||||
const result = await cloudRequest(c, async ({ storeId }) => createCloudGiftCards(c, storeId, c.req.valid('json')))
|
||||
if (isCloudError(result)) return c.json(result, 502)
|
||||
return c.json(result, 201)
|
||||
})
|
||||
@@ -175,3 +172,14 @@ async function cloudRequest<T>(
|
||||
function isCloudError(result: unknown): result is { error: string } {
|
||||
return Boolean(result && typeof result === 'object' && 'error' in result)
|
||||
}
|
||||
|
||||
async function createCloudGiftCards(c: RouteContext, storeId: string, payload: object) {
|
||||
const binding = await getCloudStoreBinding(c.get('platform').db)
|
||||
const data = await requestBoundCloudJson(
|
||||
getCloudBaseUrl(c),
|
||||
`/api/stores/${encodeURIComponent(storeId)}/gift-cards`,
|
||||
binding.refreshToken,
|
||||
{ method: 'POST', payload },
|
||||
)
|
||||
return cloudGiftCardCreateResponseSchema.parse(data)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { checkoutInputSchema, redeemGiftCardInputSchema } from '@shared/schemas'
|
||||
import {
|
||||
checkoutInputSchema,
|
||||
cloudCreditBalanceResponseSchema,
|
||||
cloudCreditLedgerResponseSchema,
|
||||
redeemGiftCardInputSchema,
|
||||
redeemGiftCardResponseSchema,
|
||||
} from '@shared/schemas'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAuth } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { requireFeature } from '../../middleware/require-feature'
|
||||
import { canAccessTargetOrg, getAccessibleTargets, getCustomerLabel } from '../../services/cloud-store'
|
||||
import {
|
||||
canAccessTargetOrg,
|
||||
getAccessibleTargets,
|
||||
getCloudStoreBinding,
|
||||
getCustomerLabel,
|
||||
} from '../../services/cloud-store'
|
||||
import { getEffectiveQuota } from '../../services/effective-quota'
|
||||
import { requestBoundCloudJson } from '../../services/licensing-cloud'
|
||||
import {
|
||||
cloudBillingPortalSessionResponseSchema,
|
||||
cloudCheckoutResponseSchema,
|
||||
@@ -15,6 +27,7 @@ import {
|
||||
cloudPackageResponseSchema,
|
||||
cloudStoreOrdersQuerySchema,
|
||||
getBoundCloudClient,
|
||||
getCloudBaseUrl,
|
||||
getUserStoreSettings,
|
||||
type RouteContext,
|
||||
unwrapCloudResponse,
|
||||
@@ -46,49 +59,43 @@ export const cloudStore = new Hono<Env>()
|
||||
const items = await getAccessibleTargets(db, c.get('userId')!)
|
||||
return c.json({ items, total: items.length })
|
||||
})
|
||||
.get('/wallet', async (c) => {
|
||||
.get('/credits', async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
const store = await getUserStoreSettings(c.get('platform').db)
|
||||
if ('error' in store) return c.json({ error: store.error }, 403)
|
||||
const result = await cloudRequest(c, async ({ client, storeId }) =>
|
||||
const result = await cloudRequest(c, async ({ storeId }) =>
|
||||
unwrapCloudResponse(
|
||||
await client.stores[':storeId'].wallets[':customerId'].balances.$get({
|
||||
param: { storeId, customerId: targetOrgId },
|
||||
query: {},
|
||||
}),
|
||||
await getCloudCreditResource(c, storeId, targetOrgId, 'balance'),
|
||||
cloudCreditBalanceResponseSchema,
|
||||
),
|
||||
)
|
||||
if (isCloudError(result)) return c.json(result, 502)
|
||||
return c.json(result)
|
||||
})
|
||||
.get('/wallet/transactions', async (c) => {
|
||||
.get('/credits/ledger-entries', async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
const store = await getUserStoreSettings(c.get('platform').db)
|
||||
if ('error' in store) return c.json({ error: store.error }, 403)
|
||||
const result = await cloudRequest(c, async ({ client, storeId }) =>
|
||||
const result = await cloudRequest(c, async ({ storeId }) =>
|
||||
unwrapCloudResponse(
|
||||
await client.stores[':storeId'].wallets[':customerId'].transactions.$get({
|
||||
param: { storeId, customerId: targetOrgId },
|
||||
query: {},
|
||||
}),
|
||||
await getCloudCreditResource(c, storeId, targetOrgId, 'ledger-entries'),
|
||||
cloudCreditLedgerResponseSchema,
|
||||
),
|
||||
)
|
||||
if (isCloudError(result)) return c.json(result, 502)
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/gift-cards/redeem', zValidator('json', redeemGiftCardInputSchema), async (c) => {
|
||||
.post('/credits/redemptions', zValidator('json', redeemGiftCardInputSchema), async (c) => {
|
||||
const targetOrgId = c.get('orgId')
|
||||
if (!targetOrgId) return c.json({ error: 'No active organization' }, 400)
|
||||
const store = await getUserStoreSettings(c.get('platform').db)
|
||||
if ('error' in store) return c.json({ error: store.error }, 403)
|
||||
const result = await cloudRequest(c, async ({ client, storeId }) =>
|
||||
const result = await cloudRequest(c, async ({ storeId }) =>
|
||||
unwrapCloudResponse(
|
||||
await client.stores[':storeId'].wallets[':customerId'].redemptions.$post({
|
||||
param: { storeId, customerId: targetOrgId },
|
||||
json: { codes: [c.req.valid('json').code] },
|
||||
}),
|
||||
await postCloudCreditRedemption(c, storeId, targetOrgId, [c.req.valid('json').code]),
|
||||
redeemGiftCardResponseSchema,
|
||||
),
|
||||
)
|
||||
if (isCloudError(result)) return c.json(result, 502)
|
||||
@@ -261,6 +268,50 @@ function orderBelongsToTarget(target: Record<string, unknown> | null, targetOrgI
|
||||
return target?.orgId === targetOrgId || target?.customerId === targetOrgId
|
||||
}
|
||||
|
||||
function cloudCreditPath(storeId: string, customerId: string, resource: string) {
|
||||
return `/api/stores/${encodeURIComponent(storeId)}/credit-accounts/${encodeURIComponent(customerId)}/${resource}`
|
||||
}
|
||||
|
||||
async function getCloudCreditResource(
|
||||
c: RouteContext,
|
||||
storeId: string,
|
||||
customerId: string,
|
||||
resource: 'balance' | 'ledger-entries',
|
||||
) {
|
||||
const binding = await getCloudStoreBinding(c.get('platform').db)
|
||||
const data = await requestBoundCloudJson(
|
||||
getCloudBaseUrl(c),
|
||||
cloudCreditPath(storeId, customerId, resource),
|
||||
binding.refreshToken,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
return jsonResponse(data)
|
||||
}
|
||||
|
||||
async function postCloudCreditRedemption(c: RouteContext, storeId: string, customerId: string, codes: string[]) {
|
||||
const binding = await getCloudStoreBinding(c.get('platform').db)
|
||||
const data = await requestBoundCloudJson(
|
||||
getCloudBaseUrl(c),
|
||||
cloudCreditPath(storeId, customerId, 'redemptions'),
|
||||
binding.refreshToken,
|
||||
{
|
||||
method: 'POST',
|
||||
payload: { codes },
|
||||
},
|
||||
)
|
||||
return jsonResponse(data, 201)
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => data,
|
||||
}
|
||||
}
|
||||
|
||||
async function cloudRequest<T>(
|
||||
c: RouteContext,
|
||||
request: (context: Awaited<ReturnType<typeof getBoundCloudClient>>) => Promise<T>,
|
||||
|
||||
@@ -102,7 +102,7 @@ async function setTrafficQuota(db: Database, orgId: string) {
|
||||
}
|
||||
|
||||
describe('object download cloud traffic reporting', () => {
|
||||
it('queues successful object downloads for Cloud sync after presigning', async () => {
|
||||
it('reports successful object downloads to Cloud before returning the presigned URL', async () => {
|
||||
const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
@@ -115,18 +115,18 @@ describe('object download cloud traffic reporting', () => {
|
||||
const res = await app.request('/api/objects/m-cloud-report-ok', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ orgId, source: 'object_download', sourceId: 'm-cloud-report-ok', bytes: 100, status: 'pending' },
|
||||
{ orgId, source: 'object_download', sourceId: 'm-cloud-report-ok', bytes: 100, status: 'reported' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not call Cloud or deny downloads when Cloud would block usage during sync', async () => {
|
||||
it('denies object downloads and refunds local traffic when Cloud rejects usage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'overage_cap_exceeded' } }, 429)),
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'insufficient_credits' } }, 402)),
|
||||
)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -136,16 +136,24 @@ describe('object download cloud traffic reporting', () => {
|
||||
|
||||
const res = await app.request('/api/objects/m-cloud-report-blocked', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
error: 'insufficient_credits',
|
||||
code: 'insufficient_credits',
|
||||
resource: 'traffic_egress',
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
const rows = await db.all<{ trafficUsed: number }>(
|
||||
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(rows[0].trafficUsed).toBe(125)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'pending' }])
|
||||
expect(rows[0].trafficUsed).toBe(25)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ status: 'blocked', error: 'insufficient_credits' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not call Cloud or refund local traffic when Cloud would return a mismatched event id during sync', async () => {
|
||||
it('records a failed report without refunding local traffic when Cloud returns a mismatched event id', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal(
|
||||
@@ -161,15 +169,15 @@ describe('object download cloud traffic reporting', () => {
|
||||
const res = await app.request('/api/objects/m-cloud-report-mismatch', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const rows = await db.all<{ trafficUsed: number }>(
|
||||
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(rows[0].trafficUsed).toBe(125)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'pending' }])
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'failed' }])
|
||||
})
|
||||
|
||||
it('does not report usage when presign fails and local traffic is refunded', async () => {
|
||||
it('keeps the pre-presign Cloud report and refunds local traffic when presign fails', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
@@ -183,17 +191,17 @@ describe('object download cloud traffic reporting', () => {
|
||||
const res = await app.request('/api/objects/m-cloud-report-presign-fail', { headers })
|
||||
|
||||
expect(res.status).toBe(500)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const rows = await db.all<{ trafficUsed: number }>(
|
||||
sql`SELECT traffic_used AS trafficUsed FROM org_quotas WHERE org_id = ${orgId}`,
|
||||
)
|
||||
expect(rows[0].trafficUsed).toBe(25)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(0)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'reported' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('public redirect cloud traffic reporting', () => {
|
||||
it('queues direct share redirects for Cloud sync', async () => {
|
||||
it('reports direct share redirects to Cloud', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
@@ -208,16 +216,16 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
|
||||
expect(res.status).toBe(302)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ source: 'direct_share', sourceId: share.id, bytes: 100, status: 'pending' },
|
||||
{ source: 'direct_share', sourceId: share.id, bytes: 100, status: 'reported' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not call Cloud or deny direct share redirects when Cloud would block usage during sync', async () => {
|
||||
it('denies direct share redirects and rolls back local counters when Cloud rejects usage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'overage_cap_exceeded' } }, 429)),
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'insufficient_credits' } }, 402)),
|
||||
)
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -229,19 +237,27 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
|
||||
const res = await app.request(`/r/${share.token}`, { redirect: 'manual' })
|
||||
|
||||
expect(res.status).toBe(302)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
error: 'insufficient_credits',
|
||||
code: 'insufficient_credits',
|
||||
resource: 'traffic_egress',
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
const rows = await db.all<{ downloads: number; trafficUsed: number }>(sql`
|
||||
SELECT s.downloads, q.traffic_used AS trafficUsed
|
||||
FROM shares s
|
||||
INNER JOIN org_quotas q ON q.org_id = s.org_id
|
||||
WHERE s.id = ${share.id}
|
||||
`)
|
||||
expect(rows[0]).toEqual({ downloads: 1, trafficUsed: 125 })
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'pending' }])
|
||||
expect(rows[0]).toEqual({ downloads: 0, trafficUsed: 25 })
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ status: 'blocked', error: 'insufficient_credits' },
|
||||
])
|
||||
})
|
||||
|
||||
it('queues landing share downloads for Cloud sync', async () => {
|
||||
it('reports landing share downloads to Cloud', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
@@ -257,16 +273,16 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ source: 'landing_share', sourceId: share.id, bytes: 100, status: 'pending' },
|
||||
{ source: 'landing_share', sourceId: share.id, bytes: 100, status: 'reported' },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not call Cloud or deny landing share downloads when Cloud would block usage during sync', async () => {
|
||||
it('denies landing share downloads and rolls back local counters when Cloud rejects usage', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'overage_cap_exceeded' } }, 429)),
|
||||
vi.fn().mockResolvedValue(makeCloudResponse({ error: { code: 'insufficient_credits' } }, 402)),
|
||||
)
|
||||
await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -279,16 +295,24 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
|
||||
const res = await app.request(`/api/shares/${share.token}/objects/${ref}?downloadUrl=1`, { redirect: 'manual' })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
error: 'insufficient_credits',
|
||||
code: 'insufficient_credits',
|
||||
resource: 'traffic_egress',
|
||||
})
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
expect(S3Service.prototype.presignDownload).not.toHaveBeenCalled()
|
||||
const rows = await db.all<{ downloads: number; trafficUsed: number }>(sql`
|
||||
SELECT s.downloads, q.traffic_used AS trafficUsed
|
||||
FROM shares s
|
||||
INNER JOIN org_quotas q ON q.org_id = s.org_id
|
||||
WHERE s.id = ${share.id}
|
||||
`)
|
||||
expect(rows[0]).toEqual({ downloads: 1, trafficUsed: 125 })
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'pending' }])
|
||||
expect(rows[0]).toEqual({ downloads: 0, trafficUsed: 25 })
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ status: 'blocked', error: 'insufficient_credits' },
|
||||
])
|
||||
})
|
||||
|
||||
it('still returns landing share URLs when audit recording fails after local traffic queue', async () => {
|
||||
@@ -311,7 +335,7 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
expect(consoleError).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues token image-hosting redirects for Cloud sync', async () => {
|
||||
it('reports token image-hosting redirects to Cloud', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
@@ -325,7 +349,7 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
|
||||
expect(res.status).toBe(302)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ source: 'image_hosting', sourceId: 'ih-cloud-token', bytes: 100, status: 'pending' },
|
||||
{ source: 'image_hosting', sourceId: 'ih-cloud-token', bytes: 100, status: 'reported' },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -365,7 +389,7 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('queues custom-domain image-hosting redirects for Cloud sync', async () => {
|
||||
it('reports custom-domain image-hosting redirects to Cloud', async () => {
|
||||
const { app, db } = await createTestApp({ PUBLIC_APP_HOST: 'zpan.example.com' })
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(acceptedUsageResponse))
|
||||
@@ -382,7 +406,7 @@ describe('public redirect cloud traffic reporting', () => {
|
||||
|
||||
expect(res.status).toBe(302)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ source: 'custom_domain_image', sourceId: 'ih-cloud-domain', bytes: 100, status: 'pending' },
|
||||
{ source: 'custom_domain_image', sourceId: 'ih-cloud-domain', bytes: 100, status: 'reported' },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -699,7 +699,7 @@ describe('Objects API', () => {
|
||||
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
|
||||
})
|
||||
|
||||
it('GET /api/objects/:id queues Cloud traffic for bound instances without calling Cloud', async () => {
|
||||
it('GET /api/objects/:id reports Cloud traffic for bound instances before returning the URL', async () => {
|
||||
const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -715,21 +715,31 @@ describe('Objects API', () => {
|
||||
cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
lastRefreshAt: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (_url, init) => {
|
||||
const body = JSON.parse(String(init?.body)) as { eventId: string }
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: { accepted: true, duplicate: false, eventId: body.eventId } }),
|
||||
} as Response
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await app.request('/api/objects/m1', { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{
|
||||
orgId,
|
||||
source: 'object_download',
|
||||
sourceId: 'm1',
|
||||
bytes: 100,
|
||||
status: 'pending',
|
||||
status: 'reported',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -200,14 +200,6 @@ const app = new Hono<Env>()
|
||||
const trafficAllowed = await consumeTrafficIfQuotaAllows(db, orgId, matter.size ?? 0)
|
||||
if (!trafficAllowed) return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
|
||||
let downloadUrl: string
|
||||
try {
|
||||
downloadUrl = await s3.presignDownload(storage, matter.object, matter.name)
|
||||
} catch (e) {
|
||||
await refundTraffic(db, orgId, matter.size ?? 0)
|
||||
throw e
|
||||
}
|
||||
|
||||
const trafficReportError = await reportTrafficForDownload(c, {
|
||||
orgId,
|
||||
bytes: matter.size ?? 0,
|
||||
@@ -216,6 +208,14 @@ const app = new Hono<Env>()
|
||||
})
|
||||
if (trafficReportError) return trafficReportError
|
||||
|
||||
let downloadUrl: string
|
||||
try {
|
||||
downloadUrl = await s3.presignDownload(storage, matter.object, matter.name)
|
||||
} catch (e) {
|
||||
await refundTraffic(db, orgId, matter.size ?? 0)
|
||||
throw e
|
||||
}
|
||||
|
||||
return c.json({ ...matter, downloadUrl })
|
||||
})
|
||||
.patch('/:id', requireTeamRole('editor'), zValidator('json', patchMatterSchema), async (c) => {
|
||||
|
||||
@@ -61,15 +61,6 @@ async function handleDirectShare(c: Context<Env>, db: Database, token: string):
|
||||
return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
}
|
||||
|
||||
let url: string
|
||||
try {
|
||||
url = await s3.presignDownload(storage, matter.object, matter.name, PRESIGN_TTL_SECS)
|
||||
} catch (e) {
|
||||
await refundTraffic(db, share.orgId, matter.size ?? 0)
|
||||
await decrementDownloads(db, share.id)
|
||||
throw e
|
||||
}
|
||||
|
||||
const trafficReportError = await reportTrafficForDownload(c, {
|
||||
orgId: share.orgId,
|
||||
bytes: matter.size ?? 0,
|
||||
@@ -79,6 +70,15 @@ async function handleDirectShare(c: Context<Env>, db: Database, token: string):
|
||||
})
|
||||
if (trafficReportError) return trafficReportError
|
||||
|
||||
let url: string
|
||||
try {
|
||||
url = await s3.presignDownload(storage, matter.object, matter.name, PRESIGN_TTL_SECS)
|
||||
} catch (e) {
|
||||
await refundTraffic(db, share.orgId, matter.size ?? 0)
|
||||
await decrementDownloads(db, share.id)
|
||||
throw e
|
||||
}
|
||||
|
||||
const res = c.redirect(url, 302)
|
||||
res.headers.set('Cache-Control', 'no-store')
|
||||
return res
|
||||
|
||||
@@ -283,6 +283,15 @@ export const publicShares = new Hono<Env>()
|
||||
return c.json({ error: 'Traffic quota exceeded' }, 422)
|
||||
}
|
||||
|
||||
const trafficReportError = await reportTrafficForDownload(c, {
|
||||
orgId: share.orgId,
|
||||
bytes: targetMatter.size ?? 0,
|
||||
source: 'landing_share',
|
||||
sourceId: share.id,
|
||||
onRejected: () => decrementDownloads(db, share.id),
|
||||
})
|
||||
if (trafficReportError) return trafficReportError
|
||||
|
||||
// Record download audit event. Use the authenticated viewer if available;
|
||||
// fall back to the share creator as the org-attributed actor for anonymous
|
||||
// downloads. The presigned URL is never stored in metadata.
|
||||
@@ -296,15 +305,6 @@ export const publicShares = new Hono<Env>()
|
||||
throw e
|
||||
}
|
||||
|
||||
const trafficReportError = await reportTrafficForDownload(c, {
|
||||
orgId: share.orgId,
|
||||
bytes: targetMatter.size ?? 0,
|
||||
source: 'landing_share',
|
||||
sourceId: share.id,
|
||||
onRejected: () => decrementDownloads(db, share.id),
|
||||
})
|
||||
if (trafficReportError) return trafficReportError
|
||||
|
||||
try {
|
||||
await recordActivity(db, {
|
||||
orgId: share.orgId,
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function reportTrafficForDownload(
|
||||
await refundTraffic(c.get('platform').db, params.orgId, params.bytes)
|
||||
await params.onRejected?.()
|
||||
if (error instanceof CloudTrafficBlockedError) {
|
||||
return c.json({ error: 'Cloud traffic overage cap exceeded' }, 429)
|
||||
return c.json({ error: 'insufficient_credits', code: 'insufficient_credits', resource: 'traffic_egress' }, 402)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -32,10 +32,13 @@ describe('cloud traffic metering', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('queues traffic egress locally without calling Cloud from the request path', async () => {
|
||||
it('reports traffic egress to Cloud from the request path', async () => {
|
||||
const { db, platform } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(makeResponse({ data: { accepted: true, duplicate: false, eventId: 'evt_1' } })),
|
||||
)
|
||||
|
||||
const result = await reportTrafficEgress({
|
||||
platform,
|
||||
@@ -46,9 +49,9 @@ describe('cloud traffic metering', () => {
|
||||
eventId: 'evt_1',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ status: 'pending', eventId: 'evt_1', duplicate: false })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'pending' }])
|
||||
expect(result).toMatchObject({ status: 'reported', eventId: 'evt_1', duplicate: false })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'reported' }])
|
||||
})
|
||||
|
||||
it('syncs pending traffic reports to Cloud outside the request path', async () => {
|
||||
@@ -69,7 +72,7 @@ describe('cloud traffic metering', () => {
|
||||
|
||||
const result = await syncPendingCloudTrafficReports({ db, cloudBaseUrl: 'https://cloud.example' })
|
||||
|
||||
expect(result).toEqual({ attempted: 1, reported: 1, blocked: 0, failed: 0 })
|
||||
expect(result).toEqual({ attempted: 0, reported: 0, blocked: 0, failed: 0 })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('https://cloud.example/api/stores/store-test-binding/billing/usage-events')
|
||||
@@ -101,7 +104,7 @@ describe('cloud traffic metering', () => {
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps idempotent reports local after the first queued report', async () => {
|
||||
it('keeps idempotent reports local after the first reported request', async () => {
|
||||
const { db, platform } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal(
|
||||
@@ -121,8 +124,8 @@ describe('cloud traffic metering', () => {
|
||||
const second = await reportTrafficEgress(input)
|
||||
|
||||
expect(first.duplicate).toBe(false)
|
||||
expect(second).toMatchObject({ duplicate: true, eventId: 'evt_dup', status: 'pending' })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(second).toMatchObject({ duplicate: true, eventId: 'evt_dup', status: 'reported' })
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -155,29 +158,24 @@ describe('cloud traffic metering', () => {
|
||||
).rejects.toThrow('traffic_report_idempotency_conflict')
|
||||
})
|
||||
|
||||
it('records Cloud cap rejection during background sync', async () => {
|
||||
it('records Cloud credit rejection during the request path', async () => {
|
||||
const { db, platform } = await createTestApp()
|
||||
await seedTrafficBinding(db)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse({ error: { code: 'overage_cap_exceeded' } }, 429)))
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(makeResponse({ error: { code: 'insufficient_credits' } }, 402)))
|
||||
|
||||
await reportTrafficEgress({
|
||||
platform,
|
||||
orgId: 'org_1',
|
||||
bytes: 1024,
|
||||
source: 'landing_share',
|
||||
sourceId: 'share_1',
|
||||
eventId: 'evt_blocked',
|
||||
})
|
||||
|
||||
await expect(syncPendingCloudTrafficReports({ db, cloudBaseUrl: 'https://cloud.example' })).resolves.toEqual({
|
||||
attempted: 1,
|
||||
reported: 0,
|
||||
blocked: 1,
|
||||
failed: 0,
|
||||
})
|
||||
await expect(
|
||||
reportTrafficEgress({
|
||||
platform,
|
||||
orgId: 'org_1',
|
||||
bytes: 1024,
|
||||
source: 'landing_share',
|
||||
sourceId: 'share_1',
|
||||
eventId: 'evt_blocked',
|
||||
}),
|
||||
).rejects.toThrow(CloudTrafficBlockedError)
|
||||
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ eventId: 'evt_blocked', status: 'blocked', error: 'overage_cap_exceeded' },
|
||||
{ eventId: 'evt_blocked', status: 'blocked', error: 'insufficient_credits' },
|
||||
])
|
||||
await expect(
|
||||
reportTrafficEgress({
|
||||
@@ -211,12 +209,12 @@ describe('cloud traffic metering', () => {
|
||||
eventId: 'evt_retry',
|
||||
now: new Date('2026-04-30T23:59:00.000Z'),
|
||||
}
|
||||
await reportTrafficEgress(input)
|
||||
await expect(reportTrafficEgress(input)).resolves.toMatchObject({ status: 'failed', eventId: 'evt_retry' })
|
||||
await expect(syncPendingCloudTrafficReports({ db, cloudBaseUrl: 'https://cloud.example' })).resolves.toEqual({
|
||||
attempted: 1,
|
||||
reported: 0,
|
||||
reported: 1,
|
||||
blocked: 0,
|
||||
failed: 1,
|
||||
failed: 0,
|
||||
})
|
||||
|
||||
const result = await syncPendingCloudTrafficReports({
|
||||
@@ -225,7 +223,7 @@ describe('cloud traffic metering', () => {
|
||||
now: new Date('2026-05-01T00:01:00.000Z'),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ attempted: 1, reported: 1, blocked: 0, failed: 0 })
|
||||
expect(result).toEqual({ attempted: 0, reported: 0, blocked: 0, failed: 0 })
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([
|
||||
{ status: 'reported', error: null, period: '2026-04' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { asc, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { z } from 'zod'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants'
|
||||
import { cloudTrafficReports } from '../db/schema'
|
||||
import { loadActiveLicenseBinding } from '../licensing/license-state'
|
||||
import type { Database, Platform } from '../platform/interface'
|
||||
@@ -56,12 +57,21 @@ export async function reportTrafficEgress(params: {
|
||||
}
|
||||
|
||||
const binding = await loadActiveLicenseBinding(platform.db)
|
||||
if (!binding?.refreshToken) {
|
||||
if (!binding?.refreshToken || !binding.cloudStoreId) {
|
||||
await updateTrafficReport(platform.db, eventId, 'skipped_unbound', null, now)
|
||||
return { status: 'skipped_unbound', eventId, duplicate: false }
|
||||
}
|
||||
|
||||
return { status: 'pending', eventId, duplicate: false }
|
||||
const status = await syncTrafficReport({
|
||||
db: platform.db,
|
||||
cloudBaseUrl: platform.getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT,
|
||||
refreshToken: binding.refreshToken,
|
||||
storeId: binding.cloudStoreId,
|
||||
report: (await loadTrafficReport(platform.db, eventId))!,
|
||||
now,
|
||||
})
|
||||
if (status === 'blocked') throw new CloudTrafficBlockedError()
|
||||
return { status, eventId, duplicate: false }
|
||||
}
|
||||
|
||||
export async function syncPendingCloudTrafficReports(params: {
|
||||
@@ -124,7 +134,7 @@ async function syncTrafficReport(params: {
|
||||
return 'reported'
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'cloud_usage_report_failed'
|
||||
if (message === 'overage_cap_exceeded') {
|
||||
if (message === 'insufficient_credits' || message === 'overage_cap_exceeded') {
|
||||
await updateTrafficReport(db, report.eventId, 'blocked', message, now)
|
||||
return 'blocked'
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
orderListResponseSchema,
|
||||
productPriceSchema,
|
||||
updateProductSchema,
|
||||
walletBalanceListResponseSchema,
|
||||
walletBalanceSchema,
|
||||
walletLedgerEntrySchema,
|
||||
walletLedgerResponseSchema,
|
||||
zpanCloudEventSchema,
|
||||
} from 'zpan-cloud-sdk'
|
||||
|
||||
@@ -188,8 +184,7 @@ export const checkoutInputSchema = z.object({
|
||||
export const giftCardStatusSchema = z.enum(['active', 'redeemed', 'disabled', 'expired', 'revoked'])
|
||||
|
||||
export const createGiftCardInputSchema = z.object({
|
||||
amount: z.number().int().positive(),
|
||||
currency: cloudStoreCurrencySchema,
|
||||
credits: z.number().int().positive(),
|
||||
expiresAt: z.string().datetime().optional(),
|
||||
count: z.number().int().min(1).max(100),
|
||||
})
|
||||
@@ -300,16 +295,65 @@ export type CreateGiftCardInput = z.input<typeof createGiftCardInputSchema>
|
||||
export type DisableGiftCardInput = z.infer<typeof disableGiftCardSchema>
|
||||
export type CloudOrderQuotaChange = z.infer<typeof cloudOrderQuotaChangeSchema>
|
||||
|
||||
export const cloudWalletBalanceSchema = walletBalanceSchema
|
||||
export const cloudWalletResponseSchema = walletBalanceListResponseSchema
|
||||
export const cloudCreditBalanceResponseSchema = z.object({
|
||||
balance: z.number().int(),
|
||||
})
|
||||
|
||||
export type CloudWalletResponse = z.infer<typeof cloudWalletResponseSchema>
|
||||
export const cloudCreditBucketSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
creditAccountId: z.string().min(1),
|
||||
storeId: z.string().min(1),
|
||||
customerId: z.string().nullable(),
|
||||
sourceType: z.enum(['subscription_grant', 'top_up', 'gift_card_redemption', 'admin_grant']),
|
||||
sourceId: z.string().min(1),
|
||||
originalCredits: z.number().int(),
|
||||
remainingCredits: z.number().int(),
|
||||
expiresAt: z.string().nullable(),
|
||||
updatedAt: z.string().min(1),
|
||||
})
|
||||
|
||||
export const cloudWalletTransactionSchema = walletLedgerEntrySchema
|
||||
export const cloudWalletTransactionsResponseSchema = walletLedgerResponseSchema
|
||||
export const cloudCreditLedgerEntrySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
creditAccountId: z.string().nullable(),
|
||||
creditBucketId: z.string().nullable(),
|
||||
storeId: z.string().min(1),
|
||||
customerId: z.string().nullable(),
|
||||
amount: z.number().int(),
|
||||
direction: z.enum(['credit', 'debit']),
|
||||
status: z.enum(['posted', 'reversed']),
|
||||
sourceType: z.enum([
|
||||
'subscription_grant',
|
||||
'top_up',
|
||||
'gift_card_redemption',
|
||||
'admin_grant',
|
||||
'usage_charge',
|
||||
'adjustment',
|
||||
]),
|
||||
sourceId: z.string().min(1),
|
||||
orderId: z.string().nullable(),
|
||||
paymentId: z.string().nullable(),
|
||||
createdAt: z.string().min(1),
|
||||
})
|
||||
|
||||
export type CloudWalletTransaction = z.infer<typeof cloudWalletTransactionSchema>
|
||||
export type CloudWalletTransactionsResponse = z.infer<typeof cloudWalletTransactionsResponseSchema>
|
||||
export const cloudCreditBucketsResponseSchema = z.object({
|
||||
items: z.array(cloudCreditBucketSchema),
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
})
|
||||
|
||||
export const cloudCreditLedgerResponseSchema = z.object({
|
||||
items: z.array(cloudCreditLedgerEntrySchema),
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
})
|
||||
|
||||
export type CloudCreditBalanceResponse = z.infer<typeof cloudCreditBalanceResponseSchema>
|
||||
export type CloudCreditBucket = z.infer<typeof cloudCreditBucketSchema>
|
||||
export type CloudCreditBucketsResponse = z.infer<typeof cloudCreditBucketsResponseSchema>
|
||||
export type CloudCreditLedgerEntry = z.infer<typeof cloudCreditLedgerEntrySchema>
|
||||
export type CloudCreditLedgerResponse = z.infer<typeof cloudCreditLedgerResponseSchema>
|
||||
|
||||
export const redeemGiftCardInputSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
@@ -318,25 +362,8 @@ export const redeemGiftCardInputSchema = z.object({
|
||||
export type RedeemGiftCardInput = z.infer<typeof redeemGiftCardInputSchema>
|
||||
|
||||
export const redeemGiftCardResponseSchema = z.object({
|
||||
redeemedAmount: z.number().int().min(0),
|
||||
currency: z.string().nullable(),
|
||||
entries: z.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
storeId: z.string().min(1),
|
||||
customerId: z.string().nullable(),
|
||||
currency: z.string().min(1),
|
||||
amount: z.number().int().min(0),
|
||||
direction: z.enum(['credit', 'debit']),
|
||||
status: z.string().min(1),
|
||||
sourceType: z.string().min(1),
|
||||
sourceId: z.string().min(1),
|
||||
orderId: z.string().nullable().optional(),
|
||||
paymentId: z.string().nullable().optional(),
|
||||
stripeCustomerBalanceTransactionId: z.string().nullable().optional(),
|
||||
createdAt: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
redeemedCredits: z.number().int().min(0),
|
||||
entries: z.array(cloudCreditLedgerEntrySchema),
|
||||
failures: z.array(
|
||||
z.object({
|
||||
code: z.string().min(1),
|
||||
|
||||
+10
-7
@@ -32,6 +32,11 @@ export {
|
||||
} from './background-jobs'
|
||||
export type {
|
||||
CheckoutInput,
|
||||
CloudCreditBalanceResponse,
|
||||
CloudCreditBucket,
|
||||
CloudCreditBucketsResponse,
|
||||
CloudCreditLedgerEntry,
|
||||
CloudCreditLedgerResponse,
|
||||
CloudOrder,
|
||||
CloudOrderFulfillmentPayload,
|
||||
CloudOrderItem,
|
||||
@@ -39,9 +44,6 @@ export type {
|
||||
CloudProductInput,
|
||||
CloudProductPatchInput,
|
||||
CloudStoreSettingsInput,
|
||||
CloudWalletResponse,
|
||||
CloudWalletTransaction,
|
||||
CloudWalletTransactionsResponse,
|
||||
CreateGiftCardInput,
|
||||
DisableGiftCardInput,
|
||||
GiftCardStatus,
|
||||
@@ -50,6 +52,11 @@ export type {
|
||||
} from './cloud-store'
|
||||
export {
|
||||
checkoutInputSchema,
|
||||
cloudCreditBalanceResponseSchema,
|
||||
cloudCreditBucketSchema,
|
||||
cloudCreditBucketsResponseSchema,
|
||||
cloudCreditLedgerEntrySchema,
|
||||
cloudCreditLedgerResponseSchema,
|
||||
cloudOrderFulfillmentPayloadSchema,
|
||||
cloudOrderItemSchema,
|
||||
cloudOrderQuotaChangeSchema,
|
||||
@@ -58,10 +65,6 @@ export {
|
||||
cloudProductInputSchema,
|
||||
cloudProductPatchSchema,
|
||||
cloudStoreSettingsSchema,
|
||||
cloudWalletBalanceSchema,
|
||||
cloudWalletResponseSchema,
|
||||
cloudWalletTransactionSchema,
|
||||
cloudWalletTransactionsResponseSchema,
|
||||
createGiftCardInputSchema,
|
||||
disableGiftCardSchema,
|
||||
giftCardStatusSchema,
|
||||
|
||||
+16
-2
@@ -1,4 +1,4 @@
|
||||
import type { CommercePayment, CommerceProduct, ProductPrice, StoreGiftCard } from 'zpan-cloud-sdk'
|
||||
import type { CommercePayment, CommerceProduct, ProductPrice } from 'zpan-cloud-sdk'
|
||||
import type { DirType, ObjectStatus, StorageMode, StorageStatus } from '../constants'
|
||||
import type {
|
||||
CloudOrder as ZPanCloudOrder,
|
||||
@@ -93,7 +93,21 @@ export interface CloudOrderTarget {
|
||||
|
||||
export type CloudOrderPayment = CommercePayment
|
||||
export type CloudOrder = ZPanCloudOrder
|
||||
export type CloudGiftCard = StoreGiftCard
|
||||
export interface CloudGiftCard {
|
||||
id: string
|
||||
storeId: string
|
||||
campaignId: string | null
|
||||
code: string | null
|
||||
codeLast4: string
|
||||
credits: number
|
||||
status: 'active' | 'redeemed' | 'disabled' | 'expired' | 'revoked'
|
||||
expiresAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
disabledAt: string | null
|
||||
revokedAt: string | null
|
||||
createdByAdmin: string
|
||||
}
|
||||
|
||||
export interface CloudStoreTarget {
|
||||
orgId: string
|
||||
|
||||
@@ -18,11 +18,9 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatMoney } from '@/lib/format'
|
||||
|
||||
export const emptyGiftCardForm = {
|
||||
amount: '10',
|
||||
currency: 'usd',
|
||||
credits: '1000',
|
||||
expiresAt: '',
|
||||
count: '1',
|
||||
}
|
||||
@@ -30,8 +28,7 @@ export type GiftCardFormState = typeof emptyGiftCardForm
|
||||
|
||||
export function giftCardInputFromForm(form: GiftCardFormState): CreateGiftCardInput {
|
||||
const input: CreateGiftCardInput = {
|
||||
amount: Math.round(Number(form.amount) * 100),
|
||||
currency: form.currency.trim().toLowerCase(),
|
||||
credits: Math.round(Number(form.credits)),
|
||||
count: Math.round(Number(form.count)),
|
||||
}
|
||||
if (form.expiresAt) input.expiresAt = new Date(form.expiresAt).toISOString()
|
||||
@@ -111,22 +108,11 @@ function CodeGenerateForm({ form, available, pending, onFormChange, onGenerate }
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<NumberField
|
||||
id="giftCardAmount"
|
||||
label={t('admin.cloudStore.codes.amount')}
|
||||
value={form.amount}
|
||||
onChange={(amount) => onFormChange({ ...form, amount })}
|
||||
id="giftCardCredits"
|
||||
label={t('admin.cloudStore.codes.credits')}
|
||||
value={form.credits}
|
||||
onChange={(credits) => onFormChange({ ...form, credits })}
|
||||
/>
|
||||
<Field label={t('admin.cloudStore.codes.currency')} htmlFor="giftCardCurrency">
|
||||
<Select value={form.currency} onValueChange={(currency) => onFormChange({ ...form, currency })}>
|
||||
<SelectTrigger id="giftCardCurrency">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="usd">USD</SelectItem>
|
||||
<SelectItem value="cny">CNY</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<DateField value={form.expiresAt} onChange={(expiresAt) => onFormChange({ ...form, expiresAt })} />
|
||||
<NumberField
|
||||
id="codeCount"
|
||||
@@ -257,7 +243,7 @@ function CodeRow({
|
||||
<TableCell className="truncate font-mono text-xs" title={codeLabel}>
|
||||
{codeLabel}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatMoney(code.amount, code.currency)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatCredits(code.credits)}</TableCell>
|
||||
<TableCell className="truncate" title={code.expiresAt ? new Date(code.expiresAt).toLocaleString() : '-'}>
|
||||
{code.expiresAt ? new Date(code.expiresAt).toLocaleString() : '-'}
|
||||
</TableCell>
|
||||
@@ -348,6 +334,10 @@ function CodeRow({
|
||||
)
|
||||
}
|
||||
|
||||
function formatCredits(credits: number) {
|
||||
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(credits)
|
||||
}
|
||||
|
||||
function CodeStatusBadge({ code }: { code: CloudGiftCard }) {
|
||||
const { t } = useTranslation()
|
||||
const status = getCodeStatus(code)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CloudWalletTransaction } from '@shared/schemas'
|
||||
import { Wallet } from 'lucide-react'
|
||||
import type { CloudCreditLedgerEntry } from '@shared/schemas'
|
||||
import { BadgeCent } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
@@ -12,56 +12,53 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { StorageActions } from './storage-dialogs'
|
||||
|
||||
export function WalletBalanceButton({
|
||||
wallet,
|
||||
transactions,
|
||||
export function CreditBalanceButton({
|
||||
credits,
|
||||
entries,
|
||||
loading,
|
||||
onRedeem,
|
||||
isRedeeming,
|
||||
}: {
|
||||
wallet?: { balance: number; currency: string }
|
||||
transactions: CloudWalletTransaction[]
|
||||
credits?: { balance: number }
|
||||
entries: CloudCreditLedgerEntry[]
|
||||
loading: boolean
|
||||
onRedeem: (code: string) => void
|
||||
isRedeeming: boolean
|
||||
}) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const language = i18n.resolvedLanguage ?? 'en'
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm font-medium shadow-xs hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label={t('storage.viewWalletTransactions')}
|
||||
aria-label={t('storage.viewCreditActivity')}
|
||||
>
|
||||
<Wallet className="h-4 w-4" />
|
||||
{t('storage.walletButton')}
|
||||
<BadgeCent className="h-4 w-4" />
|
||||
{t('storage.creditsButton')}
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('storage.walletButton')}</DialogTitle>
|
||||
<DialogDescription>{t('storage.walletTransactionsDescription')}</DialogDescription>
|
||||
<DialogTitle>{t('storage.creditsButton')}</DialogTitle>
|
||||
<DialogDescription>{t('storage.creditActivityDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<WalletBalanceSummary wallet={wallet} language={language} onRedeem={onRedeem} isRedeeming={isRedeeming} />
|
||||
<CreditBalanceSummary credits={credits} onRedeem={onRedeem} isRedeeming={isRedeeming} />
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">{t('storage.walletTransactionsTitle')}</h3>
|
||||
<WalletTransactions entries={transactions} language={language} loading={loading} />
|
||||
<h3 className="text-sm font-medium">{t('storage.creditActivityTitle')}</h3>
|
||||
<CreditActivity entries={entries} loading={loading} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletBalanceSummary({
|
||||
wallet,
|
||||
language,
|
||||
function CreditBalanceSummary({
|
||||
credits,
|
||||
onRedeem,
|
||||
isRedeeming,
|
||||
}: {
|
||||
wallet?: { balance: number; currency: string }
|
||||
language: string
|
||||
credits?: { balance: number }
|
||||
onRedeem: (code: string) => void
|
||||
isRedeeming: boolean
|
||||
}) {
|
||||
@@ -69,9 +66,9 @@ function WalletBalanceSummary({
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-muted-foreground">{t('storage.walletBalance')}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('storage.creditBalance')}</div>
|
||||
<div className="mt-2 text-3xl font-semibold tabular-nums">
|
||||
{wallet ? formatMoney(wallet.balance, wallet.currency, language) : t('common.loading')}
|
||||
{credits ? formatCredits(credits.balance) : t('common.loading')}
|
||||
</div>
|
||||
</div>
|
||||
<StorageActions onRedeem={onRedeem} isRedeeming={isRedeeming} />
|
||||
@@ -79,48 +76,40 @@ function WalletBalanceSummary({
|
||||
)
|
||||
}
|
||||
|
||||
function WalletTransactions({
|
||||
entries,
|
||||
language,
|
||||
loading,
|
||||
}: {
|
||||
entries: CloudWalletTransaction[]
|
||||
language: string
|
||||
loading: boolean
|
||||
}) {
|
||||
function CreditActivity({ entries, loading }: { entries: CloudCreditLedgerEntry[]; loading: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (loading) return <WalletEmptyState label={t('common.loading')} />
|
||||
if (entries.length === 0) return <WalletEmptyState label={t('storage.walletTransactionsEmpty')} />
|
||||
if (loading) return <CreditEmptyState label={t('common.loading')} />
|
||||
if (entries.length === 0) return <CreditEmptyState label={t('storage.creditActivityEmpty')} />
|
||||
|
||||
return (
|
||||
<div className="max-h-[60vh] overflow-auto rounded-lg border">
|
||||
<table className="w-full caption-bottom text-left text-sm">
|
||||
<thead className="sticky top-0 border-b bg-background">
|
||||
<tr>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.walletTableType')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.walletTableChange')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.walletTableStatus')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.walletTableReference')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.walletTableDate')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.creditTableType')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.creditTableChange')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.creditTableStatus')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.creditTableReference')}</th>
|
||||
<th className="h-10 px-3 font-medium text-muted-foreground">{t('storage.creditTableDate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-b last:border-0">
|
||||
<td className="p-3 align-middle font-medium">{walletSourceLabel(entry.sourceType, t)}</td>
|
||||
<td className="p-3 align-middle font-medium">{creditSourceLabel(entry.sourceType, t)}</td>
|
||||
<td
|
||||
className={`p-3 align-middle font-mono font-semibold ${
|
||||
entry.direction === 'credit' ? 'text-emerald-600 dark:text-emerald-400' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{entry.direction === 'credit' ? '+' : '-'}
|
||||
{formatMoney(entry.amount, entry.currency, language)}
|
||||
{formatCredits(entry.amount)}
|
||||
</td>
|
||||
<td className="p-3 align-middle">
|
||||
<Badge variant="outline">{walletStatusLabel(entry.status, t)}</Badge>
|
||||
<Badge variant="outline">{creditStatusLabel(entry.status, t)}</Badge>
|
||||
</td>
|
||||
<td className="p-3 align-middle text-muted-foreground">{walletReference(entry)}</td>
|
||||
<td className="p-3 align-middle text-muted-foreground">{creditReference(entry)}</td>
|
||||
<td className="p-3 align-middle text-muted-foreground">{new Date(entry.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -130,46 +119,44 @@ function WalletTransactions({
|
||||
)
|
||||
}
|
||||
|
||||
function WalletEmptyState({ label }: { label: string }) {
|
||||
function CreditEmptyState({ label }: { label: string }) {
|
||||
return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{label}</div>
|
||||
}
|
||||
|
||||
function formatMoney(amount: number, currency: string, language: string) {
|
||||
return new Intl.NumberFormat(language, { style: 'currency', currency: currency.toUpperCase() }).format(amount / 100)
|
||||
function formatCredits(amount: number) {
|
||||
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(amount)
|
||||
}
|
||||
|
||||
function walletSourceLabel(
|
||||
sourceType: CloudWalletTransaction['sourceType'],
|
||||
function creditSourceLabel(
|
||||
sourceType: CloudCreditLedgerEntry['sourceType'],
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
) {
|
||||
switch (sourceType) {
|
||||
case 'subscription_grant':
|
||||
return t('storage.creditSourceSubscriptionGrant')
|
||||
case 'top_up':
|
||||
return t('storage.creditSourceTopUp')
|
||||
case 'gift_card_redemption':
|
||||
return t('storage.walletSourceGiftCard')
|
||||
case 'order_payment':
|
||||
return t('storage.walletSourceOrderPayment')
|
||||
case 'stripe_invoice':
|
||||
return t('storage.walletSourceStripeInvoice')
|
||||
return t('storage.creditSourceGiftCard')
|
||||
case 'admin_grant':
|
||||
return t('storage.creditSourceAdminGrant')
|
||||
case 'usage_charge':
|
||||
return t('storage.creditSourceUsageCharge')
|
||||
case 'adjustment':
|
||||
return t('storage.walletSourceAdjustment')
|
||||
case 'refund':
|
||||
return t('storage.walletSourceRefund')
|
||||
return t('storage.creditSourceAdjustment')
|
||||
}
|
||||
}
|
||||
|
||||
function walletStatusLabel(status: CloudWalletTransaction['status'], t: ReturnType<typeof useTranslation>['t']) {
|
||||
function creditStatusLabel(status: CloudCreditLedgerEntry['status'], t: ReturnType<typeof useTranslation>['t']) {
|
||||
switch (status) {
|
||||
case 'posted':
|
||||
return t('storage.walletStatusPosted')
|
||||
case 'pending':
|
||||
return t('storage.walletStatusPending')
|
||||
case 'released':
|
||||
return t('storage.walletStatusReleased')
|
||||
case 'refunded':
|
||||
return t('storage.walletStatusRefunded')
|
||||
return t('storage.creditStatusPosted')
|
||||
case 'reversed':
|
||||
return t('storage.creditStatusReversed')
|
||||
}
|
||||
}
|
||||
|
||||
function walletReference(entry: CloudWalletTransaction) {
|
||||
function creditReference(entry: CloudCreditLedgerEntry) {
|
||||
if (entry.paymentId) return entry.paymentId.slice(0, 8)
|
||||
if (entry.orderId) return entry.orderId.slice(0, 8)
|
||||
if (entry.sourceId) return entry.sourceId.slice(0, 8)
|
||||
@@ -135,7 +135,7 @@ function OrderRow({
|
||||
</div>
|
||||
{order.discountAmount > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('storage.walletCredit', {
|
||||
{t('storage.creditDiscount', {
|
||||
amount: formatMoney(order.discountAmount, order.currency, i18n.resolvedLanguage ?? 'en'),
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { CreditBalanceButton } from './credits-panel'
|
||||
export { StorageOrderHistoryContent, StorageOrderHistoryDialog } from './order-history'
|
||||
export { StorageActions } from './storage-dialogs'
|
||||
export { StoragePackages } from './storage-packages'
|
||||
export { StorageUnavailableState } from './storage-unavailable'
|
||||
export { CurrentPlanCard, FreeQuotaCard } from './storage-usage'
|
||||
export { WalletBalanceButton } from './wallet-panel'
|
||||
|
||||
+22
-24
@@ -1173,8 +1173,7 @@
|
||||
"admin.cloudStore.codes.generateTitle": "Generate gift cards",
|
||||
"admin.cloudStore.codes.generateDescription": "Create gift cards users can apply during checkout.",
|
||||
"admin.cloudStore.codes.listTitle": "Gift cards",
|
||||
"admin.cloudStore.codes.amount": "Amount",
|
||||
"admin.cloudStore.codes.currency": "Currency",
|
||||
"admin.cloudStore.codes.credits": "Credits",
|
||||
"admin.cloudStore.codes.expiresAt": "Optional expiry",
|
||||
"admin.cloudStore.codes.count": "Batch count",
|
||||
"admin.cloudStore.codes.generate": "Generate",
|
||||
@@ -1274,26 +1273,25 @@
|
||||
"storage.usedStorage": "Used",
|
||||
"storage.trafficPeriodDetail": "Current period {{period}}",
|
||||
"storage.overCap": "Over cap",
|
||||
"storage.walletButton": "Wallet",
|
||||
"storage.walletBalance": "Account balance",
|
||||
"storage.viewWalletTransactions": "View wallet transactions",
|
||||
"storage.walletTransactionsTitle": "Wallet transactions",
|
||||
"storage.walletTransactionsDescription": "Balance changes from gift-card redemptions, wallet payments, and refunds.",
|
||||
"storage.walletTransactionsEmpty": "No wallet transactions yet.",
|
||||
"storage.walletTableType": "Type",
|
||||
"storage.walletTableChange": "Change",
|
||||
"storage.walletTableStatus": "Status",
|
||||
"storage.walletTableReference": "Reference",
|
||||
"storage.walletTableDate": "Date",
|
||||
"storage.walletSourceGiftCard": "Gift card redeemed",
|
||||
"storage.walletSourceOrderPayment": "Order payment",
|
||||
"storage.walletSourceStripeInvoice": "Stripe invoice",
|
||||
"storage.walletSourceAdjustment": "Adjustment",
|
||||
"storage.walletSourceRefund": "Refund",
|
||||
"storage.walletStatusPosted": "Posted",
|
||||
"storage.walletStatusPending": "Pending",
|
||||
"storage.walletStatusReleased": "Released",
|
||||
"storage.walletStatusRefunded": "Refunded",
|
||||
"storage.creditsButton": "Credits",
|
||||
"storage.creditBalance": "Credit balance",
|
||||
"storage.viewCreditActivity": "View credit activity",
|
||||
"storage.creditActivityTitle": "Credit activity",
|
||||
"storage.creditActivityDescription": "Credit changes from gift-card redemptions, grants, top-ups, and usage charges.",
|
||||
"storage.creditActivityEmpty": "No credit activity yet.",
|
||||
"storage.creditTableType": "Type",
|
||||
"storage.creditTableChange": "Change",
|
||||
"storage.creditTableStatus": "Status",
|
||||
"storage.creditTableReference": "Reference",
|
||||
"storage.creditTableDate": "Date",
|
||||
"storage.creditSourceSubscriptionGrant": "Subscription grant",
|
||||
"storage.creditSourceTopUp": "Top-up",
|
||||
"storage.creditSourceGiftCard": "Gift card redeemed",
|
||||
"storage.creditSourceAdminGrant": "Admin grant",
|
||||
"storage.creditSourceUsageCharge": "Usage charge",
|
||||
"storage.creditSourceAdjustment": "Adjustment",
|
||||
"storage.creditStatusPosted": "Posted",
|
||||
"storage.creditStatusReversed": "Reversed",
|
||||
"storage.target": "Target space",
|
||||
"storage.packagesTitle": "Storage plans",
|
||||
"storage.plansTitle": "Storage plans",
|
||||
@@ -1332,13 +1330,13 @@
|
||||
"storage.redeemTitle": "Redeem gift card",
|
||||
"storage.redeemDescription": "Enter a gift card and use it during package checkout.",
|
||||
"storage.redeemAction": "Redeem",
|
||||
"storage.redeemSuccess": "Redeemed successfully! {{amount}} {{currency}} added to your balance.",
|
||||
"storage.redeemSuccess": "Redeemed successfully! {{amount}} Credits added to your balance.",
|
||||
"storage.continuePayment": "Continue payment",
|
||||
"storage.cancelOrder": "Cancel order",
|
||||
"storage.cancelConfirm": "Cancel this unpaid order?",
|
||||
"storage.cancelSuccess": "Order canceled.",
|
||||
"storage.quotaChip": "{{size}} storage",
|
||||
"storage.walletCredit": "Wallet credit {{amount}}",
|
||||
"storage.creditDiscount": "Discount {{amount}}",
|
||||
"storage.giftCardCode": "Gift card code",
|
||||
|
||||
"storage.historyTitle": "Recent orders",
|
||||
|
||||
+22
-24
@@ -1173,8 +1173,7 @@
|
||||
"admin.cloudStore.codes.generateTitle": "生成礼品卡",
|
||||
"admin.cloudStore.codes.generateDescription": "创建用户可在结账时使用的礼品卡。",
|
||||
"admin.cloudStore.codes.listTitle": "礼品卡",
|
||||
"admin.cloudStore.codes.amount": "金额",
|
||||
"admin.cloudStore.codes.currency": "币种",
|
||||
"admin.cloudStore.codes.credits": "积分",
|
||||
"admin.cloudStore.codes.expiresAt": "可选过期时间",
|
||||
"admin.cloudStore.codes.count": "批量数量",
|
||||
"admin.cloudStore.codes.generate": "生成",
|
||||
@@ -1274,26 +1273,25 @@
|
||||
"storage.usedStorage": "已用",
|
||||
"storage.trafficPeriodDetail": "当前周期 {{period}}",
|
||||
"storage.overCap": "已超限",
|
||||
"storage.walletButton": "钱包",
|
||||
"storage.walletBalance": "账户余额",
|
||||
"storage.viewWalletTransactions": "查看余额流水",
|
||||
"storage.walletTransactionsTitle": "余额流水",
|
||||
"storage.walletTransactionsDescription": "展示礼品卡兑换、钱包支付和退款带来的余额变动。",
|
||||
"storage.walletTransactionsEmpty": "暂无余额流水。",
|
||||
"storage.walletTableType": "类型",
|
||||
"storage.walletTableChange": "变动",
|
||||
"storage.walletTableStatus": "状态",
|
||||
"storage.walletTableReference": "关联单号",
|
||||
"storage.walletTableDate": "时间",
|
||||
"storage.walletSourceGiftCard": "礼品卡兑换",
|
||||
"storage.walletSourceOrderPayment": "订单支付",
|
||||
"storage.walletSourceStripeInvoice": "Stripe 发票",
|
||||
"storage.walletSourceAdjustment": "调整",
|
||||
"storage.walletSourceRefund": "退款",
|
||||
"storage.walletStatusPosted": "已入账",
|
||||
"storage.walletStatusPending": "处理中",
|
||||
"storage.walletStatusReleased": "已释放",
|
||||
"storage.walletStatusRefunded": "已退款",
|
||||
"storage.creditsButton": "积分",
|
||||
"storage.creditBalance": "积分余额",
|
||||
"storage.viewCreditActivity": "查看积分流水",
|
||||
"storage.creditActivityTitle": "积分流水",
|
||||
"storage.creditActivityDescription": "展示礼品卡兑换、授予、充值和用量扣减带来的积分变动。",
|
||||
"storage.creditActivityEmpty": "暂无积分流水。",
|
||||
"storage.creditTableType": "类型",
|
||||
"storage.creditTableChange": "变动",
|
||||
"storage.creditTableStatus": "状态",
|
||||
"storage.creditTableReference": "关联单号",
|
||||
"storage.creditTableDate": "时间",
|
||||
"storage.creditSourceSubscriptionGrant": "订阅授予",
|
||||
"storage.creditSourceTopUp": "充值",
|
||||
"storage.creditSourceGiftCard": "礼品卡兑换",
|
||||
"storage.creditSourceAdminGrant": "管理员授予",
|
||||
"storage.creditSourceUsageCharge": "用量扣减",
|
||||
"storage.creditSourceAdjustment": "调整",
|
||||
"storage.creditStatusPosted": "已入账",
|
||||
"storage.creditStatusReversed": "已冲正",
|
||||
"storage.target": "目标空间",
|
||||
"storage.packagesTitle": "存储计划",
|
||||
"storage.plansTitle": "存储计划",
|
||||
@@ -1332,13 +1330,13 @@
|
||||
"storage.redeemTitle": "礼品卡兑换",
|
||||
"storage.redeemDescription": "输入礼品卡,并在计划结账时使用。",
|
||||
"storage.redeemAction": "兑换",
|
||||
"storage.redeemSuccess": "兑换成功!{{amount}} {{currency}} 已存入余额。",
|
||||
"storage.redeemSuccess": "兑换成功!{{amount}} 积分已存入余额。",
|
||||
"storage.continuePayment": "继续支付",
|
||||
"storage.cancelOrder": "取消订单",
|
||||
"storage.cancelConfirm": "确定取消这笔未支付订单吗?",
|
||||
"storage.cancelSuccess": "订单已取消。",
|
||||
"storage.quotaChip": "{{size}} 存储",
|
||||
"storage.walletCredit": "钱包抵扣 {{amount}}",
|
||||
"storage.creditDiscount": "订单优惠 {{amount}}",
|
||||
"storage.giftCardCode": "礼品卡代码",
|
||||
|
||||
"storage.historyTitle": "最近订单",
|
||||
|
||||
+23
-52
@@ -47,8 +47,8 @@ import {
|
||||
getAnnouncement,
|
||||
getBackgroundJob,
|
||||
getBranding,
|
||||
getCloudCredits,
|
||||
getCloudStoreSettings,
|
||||
getCloudWallet,
|
||||
getEmailConfig,
|
||||
getIhostConfig,
|
||||
getLicensingStatus,
|
||||
@@ -69,11 +69,11 @@ import {
|
||||
listAnnouncements,
|
||||
listAuthProviders,
|
||||
listBackgroundJobs,
|
||||
listCloudCreditLedgerEntries,
|
||||
listCloudGiftCards,
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudStoreTargets,
|
||||
listCloudWalletTransactions,
|
||||
listIhostApiKeys,
|
||||
listIhostImages,
|
||||
listNotifications,
|
||||
@@ -426,7 +426,7 @@ describe('api', () => {
|
||||
.mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
||||
|
||||
await listCloudGiftCards('active')
|
||||
const createdGiftCards = await createCloudGiftCards({ amount: 1024, currency: 'usd', count: 3 })
|
||||
const createdGiftCards = await createCloudGiftCards({ credits: 1024, count: 3 })
|
||||
await disableCloudGiftCard('ZS123')
|
||||
await deleteCloudGiftCard('ZS123')
|
||||
await listAdminCloudOrders({ limit: 100, offset: 100 })
|
||||
@@ -437,8 +437,7 @@ describe('api', () => {
|
||||
expect(calls[1][0]).toBe('/api/admin/store/gift-cards')
|
||||
expect(calls[1][1].method).toBe('POST')
|
||||
expect(JSON.parse(calls[1][1].body as string)).toEqual({
|
||||
amount: 1024,
|
||||
currency: 'usd',
|
||||
credits: 1024,
|
||||
count: 3,
|
||||
})
|
||||
expect(createdGiftCards).toEqual([{ code: 'ZS123' }])
|
||||
@@ -477,25 +476,11 @@ describe('api', () => {
|
||||
expect(calls[4][0]).toBe('/api/store/orders?limit=100&offset=100')
|
||||
})
|
||||
|
||||
it('calls wallet, wallet transactions, redemption, and order action endpoints', async () => {
|
||||
it('calls credit balance, credit activity, redemption, and order action endpoints', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
items: [
|
||||
{
|
||||
id: 'wallet-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
availableAmount: 500,
|
||||
pendingAmount: 0,
|
||||
stripeCustomerId: null,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
balance: 500,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
@@ -503,9 +488,10 @@ describe('api', () => {
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
creditAccountId: 'credit-account-1',
|
||||
creditBucketId: 'credit-bucket-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
@@ -513,7 +499,6 @@ describe('api', () => {
|
||||
sourceId: 'gc-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
stripeCustomerBalanceTransactionId: null,
|
||||
createdAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
@@ -524,8 +509,7 @@ describe('api', () => {
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
redeemedAmount: 1000,
|
||||
currency: 'usd',
|
||||
redeemedCredits: 1000,
|
||||
entries: [],
|
||||
failures: [],
|
||||
}),
|
||||
@@ -533,36 +517,21 @@ describe('api', () => {
|
||||
.mockResolvedValueOnce(makeResponse({ orderId: 'order-1', url: 'https://cloud.example/pay' }))
|
||||
.mockResolvedValueOnce(makeResponse({ id: 'order-1', status: 'canceled' }))
|
||||
|
||||
const wallet = await getCloudWallet()
|
||||
const transactions = await listCloudWalletTransactions()
|
||||
const credits = await getCloudCredits()
|
||||
const ledger = await listCloudCreditLedgerEntries()
|
||||
const redeem = await redeemCloudGiftCard('GIFT-123')
|
||||
const payment = await continueCloudOrderPayment('order-1')
|
||||
const canceled = await cancelCloudOrder('order-1')
|
||||
|
||||
expect(wallet).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'wallet-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
availableAmount: 500,
|
||||
pendingAmount: 0,
|
||||
stripeCustomerId: null,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
expect(transactions).toEqual({
|
||||
expect(credits).toEqual({ balance: 500 })
|
||||
expect(ledger).toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
creditAccountId: 'credit-account-1',
|
||||
creditBucketId: 'credit-bucket-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
@@ -570,7 +539,6 @@ describe('api', () => {
|
||||
sourceId: 'gc-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
stripeCustomerBalanceTransactionId: null,
|
||||
createdAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
@@ -578,14 +546,14 @@ describe('api', () => {
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
expect(redeem).toEqual({ redeemedAmount: 1000, currency: 'usd', entries: [], failures: [] })
|
||||
expect(redeem).toEqual({ redeemedCredits: 1000, entries: [], failures: [] })
|
||||
expect(payment).toEqual({ orderId: 'order-1', url: 'https://cloud.example/pay' })
|
||||
expect(canceled).toEqual({ id: 'order-1', status: 'canceled' })
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls as Array<[string, RequestInit]>
|
||||
expect(calls[0][0]).toBe('/api/store/wallet')
|
||||
expect(calls[1][0]).toBe('/api/store/wallet/transactions')
|
||||
expect(calls[2][0]).toBe('/api/store/gift-cards/redeem')
|
||||
expect(calls[0][0]).toBe('/api/store/credits')
|
||||
expect(calls[1][0]).toBe('/api/store/credits/ledger-entries')
|
||||
expect(calls[2][0]).toBe('/api/store/credits/redemptions')
|
||||
expect(calls[2][1].method).toBe('POST')
|
||||
expect(JSON.parse(calls[2][1].body as string)).toEqual({ code: 'GIFT-123' })
|
||||
expect(calls[3][0]).toBe('/api/store/orders/order-1/payments')
|
||||
@@ -632,12 +600,15 @@ describe('api', () => {
|
||||
],
|
||||
['deleteCloudProduct', () => deleteCloudProduct('pkg-1')],
|
||||
['listCloudGiftCards', () => listCloudGiftCards()],
|
||||
['createCloudGiftCards', () => createCloudGiftCards({ amount: 1024, currency: 'usd', count: 1 })],
|
||||
['createCloudGiftCards', () => createCloudGiftCards({ credits: 1024, count: 1 })],
|
||||
['disableCloudGiftCard', () => disableCloudGiftCard('ZS123')],
|
||||
['deleteCloudGiftCard', () => deleteCloudGiftCard('ZS123')],
|
||||
['listAdminCloudOrders', () => listAdminCloudOrders()],
|
||||
['listCloudProducts', () => listCloudProducts()],
|
||||
['listCloudStoreTargets', () => listCloudStoreTargets()],
|
||||
['getCloudCredits', () => getCloudCredits()],
|
||||
['listCloudCreditLedgerEntries', () => listCloudCreditLedgerEntries()],
|
||||
['redeemCloudGiftCard', () => redeemCloudGiftCard('GIFT-123')],
|
||||
['createCloudCheckout', () => createCloudCheckout('pkg-1')],
|
||||
['listCloudOrders', () => listCloudOrders()],
|
||||
])('throws ApiError for %s failures', async (_name, call) => {
|
||||
|
||||
+7
-7
@@ -2,10 +2,10 @@ import type { OAuthProviderConfig } from '@shared/oauth-providers'
|
||||
import type {
|
||||
AllowedImageMime,
|
||||
AnnouncementInput,
|
||||
CloudCreditBalanceResponse,
|
||||
CloudCreditLedgerResponse,
|
||||
CloudProductInput,
|
||||
CloudProductPatchInput,
|
||||
CloudWalletResponse,
|
||||
CloudWalletTransactionsResponse,
|
||||
ConflictStrategy,
|
||||
CreateBackgroundJobRequest,
|
||||
CreateGiftCardInput,
|
||||
@@ -436,16 +436,16 @@ export function listCloudStoreTargets() {
|
||||
return unwrap<{ items: CloudStoreTarget[]; total: number }>(cloudStoreApi.targets.$get())
|
||||
}
|
||||
|
||||
export function getCloudWallet() {
|
||||
return unwrap<CloudWalletResponse>(cloudStoreApi.wallet.$get())
|
||||
export function getCloudCredits() {
|
||||
return unwrap<CloudCreditBalanceResponse>(cloudStoreApi.credits.$get())
|
||||
}
|
||||
|
||||
export function listCloudWalletTransactions() {
|
||||
return unwrap<CloudWalletTransactionsResponse>(cloudStoreApi.wallet.transactions.$get())
|
||||
export function listCloudCreditLedgerEntries() {
|
||||
return unwrap<CloudCreditLedgerResponse>(cloudStoreApi.credits['ledger-entries'].$get())
|
||||
}
|
||||
|
||||
export function redeemCloudGiftCard(code: string) {
|
||||
return unwrap<RedeemGiftCardResponse>(cloudStoreApi['gift-cards'].redeem.$post({ json: { code } }))
|
||||
return unwrap<RedeemGiftCardResponse>(cloudStoreApi.credits.redemptions.$post({ json: { code } }))
|
||||
}
|
||||
|
||||
export function createCloudCheckout(packageId: string, currency?: string, priceId?: string) {
|
||||
|
||||
@@ -113,8 +113,7 @@ function giftCard(overrides: Partial<CloudGiftCard> = {}): CloudGiftCard {
|
||||
campaignId: null,
|
||||
code: null,
|
||||
codeLast4: 'ODE1',
|
||||
amount: 10_000,
|
||||
currency: 'usd',
|
||||
credits: 10_000,
|
||||
status: 'active',
|
||||
expiresAt: null,
|
||||
disabledAt: null,
|
||||
@@ -519,17 +518,14 @@ describe('AdminCloudStorePage', () => {
|
||||
fireEvent.click(view.getByRole('tab', { name: 'admin.cloudStore.tabs.codes' }))
|
||||
await waitFor(() => expect(view.getByText('****-****-****-ODE1')).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: 'admin.cloudStore.codes.generateTitle' }))
|
||||
const dialog = await view.findByRole('dialog')
|
||||
fireEvent.change(view.getByLabelText('admin.cloudStore.codes.amount'), { target: { value: '50' } })
|
||||
fireEvent.click(within(dialog).getByRole('combobox', { name: 'admin.cloudStore.codes.currency' }))
|
||||
fireEvent.click(await view.findByRole('option', { name: 'USD' }))
|
||||
await view.findByRole('dialog')
|
||||
fireEvent.change(view.getByLabelText('admin.cloudStore.codes.credits'), { target: { value: '5000' } })
|
||||
fireEvent.change(view.getByLabelText('admin.cloudStore.codes.count'), { target: { value: '3' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'admin.cloudStore.codes.generate' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(createCloudGiftCards).toHaveBeenCalledWith({
|
||||
amount: 5000,
|
||||
currency: 'usd',
|
||||
credits: 5000,
|
||||
count: 3,
|
||||
}),
|
||||
)
|
||||
@@ -572,7 +568,7 @@ describe('AdminCloudStorePage', () => {
|
||||
vi.mocked(getCloudStoreSettings).mockResolvedValue(settings())
|
||||
vi.mocked(listAdminCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudGiftCards).mockResolvedValue({
|
||||
items: [giftCard({ id: 'gift-card-2', code: null, codeLast4: 'ODE2', amount: 5000, status: 'active' })],
|
||||
items: [giftCard({ id: 'gift-card-2', code: null, codeLast4: 'ODE2', credits: 5000, status: 'active' })],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApiError,
|
||||
cancelCloudOrder,
|
||||
getCloudWallet,
|
||||
getCloudCredits,
|
||||
getUserQuota,
|
||||
listCloudCreditLedgerEntries,
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudWalletTransactions,
|
||||
redeemCloudGiftCard,
|
||||
} from '@/lib/api'
|
||||
import { openNewTab } from '@/lib/browser-navigation'
|
||||
@@ -89,11 +89,11 @@ vi.mock('@/lib/api', () => {
|
||||
ApiError: MockApiError,
|
||||
cancelCloudOrder: vi.fn(),
|
||||
getUserQuota: vi.fn(),
|
||||
getCloudWallet: vi.fn(),
|
||||
getCloudCredits: vi.fn(),
|
||||
redeemCloudGiftCard: vi.fn(),
|
||||
listCloudProducts: vi.fn(),
|
||||
listCloudOrders: vi.fn(),
|
||||
listCloudWalletTransactions: vi.fn(),
|
||||
listCloudCreditLedgerEntries: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -202,8 +202,8 @@ describe('StoragePage', () => {
|
||||
trafficPlanName: null,
|
||||
trafficExtraNames: [],
|
||||
})
|
||||
vi.mocked(getCloudWallet).mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 })
|
||||
vi.mocked(listCloudWalletTransactions).mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 })
|
||||
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 0 })
|
||||
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({ items: [], total: 0, limit: 50, offset: 0 })
|
||||
})
|
||||
|
||||
it('refreshes quota when a checkout order is delivered', async () => {
|
||||
@@ -576,27 +576,10 @@ describe('StoragePage', () => {
|
||||
expect(view.queryByRole('button', { name: 'storage.redeemTitle' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows wallet balance inside the wallet dialog', async () => {
|
||||
it('shows credit balance inside the credits dialog', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(getCloudWallet).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'wallet-1',
|
||||
walletId: null,
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
availableAmount: 1250,
|
||||
pendingAmount: 0,
|
||||
stripeCustomerId: null,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -607,46 +590,29 @@ describe('StoragePage', () => {
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
|
||||
const walletButton = view.getByLabelText('storage.viewWalletTransactions')
|
||||
expect(walletButton).toBeTruthy()
|
||||
expect(walletButton.textContent).toContain('storage.walletButton')
|
||||
expect(walletButton.textContent).not.toContain('12.50')
|
||||
const creditsButton = view.getByLabelText('storage.viewCreditActivity')
|
||||
expect(creditsButton).toBeTruthy()
|
||||
expect(creditsButton.textContent).toContain('storage.creditsButton')
|
||||
expect(creditsButton.textContent).not.toContain('1,250')
|
||||
expect(view.getByText('storage.trafficUsage')).toBeTruthy()
|
||||
fireEvent.click(walletButton)
|
||||
expect(await view.findByText('storage.walletBalance')).toBeTruthy()
|
||||
fireEvent.click(creditsButton)
|
||||
expect(await view.findByText('storage.creditBalance')).toBeTruthy()
|
||||
expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy()
|
||||
await waitFor(() => expect(view.getByText(/12\.50/)).toBeTruthy())
|
||||
await waitFor(() => expect(view.getByText('1,250')).toBeTruthy())
|
||||
})
|
||||
|
||||
it('opens wallet transactions dialog', async () => {
|
||||
it('opens credit activity dialog', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(getCloudWallet).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'wallet-1',
|
||||
walletId: null,
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
availableAmount: 1250,
|
||||
pendingAmount: 0,
|
||||
stripeCustomerId: null,
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
})
|
||||
vi.mocked(listCloudWalletTransactions).mockResolvedValue({
|
||||
vi.mocked(getCloudCredits).mockResolvedValue({ balance: 1250 })
|
||||
vi.mocked(listCloudCreditLedgerEntries).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'ledger-1',
|
||||
walletId: null,
|
||||
creditAccountId: 'credit-account-1',
|
||||
creditBucketId: 'credit-bucket-1',
|
||||
storeId: 'store-1',
|
||||
customerId: 'org-1',
|
||||
currency: 'usd',
|
||||
amount: 500,
|
||||
direction: 'credit',
|
||||
status: 'posted',
|
||||
@@ -654,7 +620,6 @@ describe('StoragePage', () => {
|
||||
sourceId: 'gift-1',
|
||||
orderId: null,
|
||||
paymentId: null,
|
||||
stripeCustomerBalanceTransactionId: null,
|
||||
createdAt: '2026-05-08T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
@@ -671,20 +636,19 @@ describe('StoragePage', () => {
|
||||
})
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.getByLabelText('storage.viewWalletTransactions')).toBeTruthy())
|
||||
fireEvent.click(view.getByLabelText('storage.viewWalletTransactions'))
|
||||
await waitFor(() => expect(view.getByLabelText('storage.viewCreditActivity')).toBeTruthy())
|
||||
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
|
||||
|
||||
expect(await view.findByText('storage.walletTransactionsTitle')).toBeTruthy()
|
||||
expect(view.getByText(/12\.50/)).toBeTruthy()
|
||||
expect(view.getByText('storage.walletSourceGiftCard')).toBeTruthy()
|
||||
expect(await view.findByText('storage.creditActivityTitle')).toBeTruthy()
|
||||
expect(view.getByText('1,250')).toBeTruthy()
|
||||
expect(view.getByText('storage.creditSourceGiftCard')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('redeems a gift card successfully', async () => {
|
||||
vi.mocked(listCloudProducts).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(listCloudOrders).mockResolvedValue({ items: [], total: 0 })
|
||||
vi.mocked(redeemCloudGiftCard).mockResolvedValue({
|
||||
redeemedAmount: 5000,
|
||||
currency: 'usd',
|
||||
redeemedCredits: 5000,
|
||||
entries: [],
|
||||
failures: [],
|
||||
})
|
||||
@@ -699,15 +663,15 @@ describe('StoragePage', () => {
|
||||
const view = renderStoragePage(queryClient)
|
||||
|
||||
await waitFor(() => expect(view.queryByText('common.loading')).toBeNull())
|
||||
fireEvent.click(view.getByLabelText('storage.viewWalletTransactions'))
|
||||
fireEvent.click(view.getByLabelText('storage.viewCreditActivity'))
|
||||
await waitFor(() => expect(view.getByRole('button', { name: 'storage.redeemTitle' })).toBeTruthy())
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.redeemTitle' }))
|
||||
fireEvent.change(view.getByLabelText('storage.giftCardCode'), { target: { value: 'ZS-1234-5678' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'storage.redeemAction' }))
|
||||
|
||||
await waitFor(() => expect(redeemCloudGiftCard).toHaveBeenCalledWith('ZS-1234-5678'))
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.redeemSuccess:50')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'wallet'] })
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.redeemSuccess:5000')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'credits'] })
|
||||
})
|
||||
|
||||
it('continues payment for an unpaid order', async () => {
|
||||
@@ -764,6 +728,6 @@ describe('StoragePage', () => {
|
||||
await waitFor(() => expect(cancelCloudOrder).toHaveBeenCalledWith('order-unpaid'))
|
||||
expect(toast.success).toHaveBeenCalledWith('storage.cancelSuccess')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'orders'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'wallet'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cloud-store', 'credits'] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,12 +4,12 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
CreditBalanceButton,
|
||||
CurrentPlanCard,
|
||||
FreeQuotaCard,
|
||||
StorageOrderHistoryDialog,
|
||||
StoragePackages,
|
||||
StorageUnavailableState,
|
||||
WalletBalanceButton,
|
||||
} from '@/components/store/storage-panels'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -23,11 +23,11 @@ import {
|
||||
import {
|
||||
ApiError,
|
||||
cancelCloudOrder,
|
||||
getCloudWallet,
|
||||
getCloudCredits,
|
||||
getUserQuota,
|
||||
listCloudCreditLedgerEntries,
|
||||
listCloudOrders,
|
||||
listCloudProducts,
|
||||
listCloudWalletTransactions,
|
||||
redeemCloudGiftCard,
|
||||
} from '@/lib/api'
|
||||
import { useActiveOrganization } from '@/lib/auth-client'
|
||||
@@ -61,15 +61,15 @@ export function StoragePage() {
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId,
|
||||
retry: false,
|
||||
})
|
||||
const walletQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'wallet', targetOrgId],
|
||||
queryFn: getCloudWallet,
|
||||
const creditsQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', targetOrgId],
|
||||
queryFn: getCloudCredits,
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId,
|
||||
retry: false,
|
||||
})
|
||||
const walletTransactionsQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'wallet', 'transactions', targetOrgId],
|
||||
queryFn: listCloudWalletTransactions,
|
||||
const creditLedgerQuery = useQuery({
|
||||
queryKey: ['cloud-store', 'credits', 'ledger-entries', targetOrgId],
|
||||
queryFn: listCloudCreditLedgerEntries,
|
||||
enabled: cloudStoreQuery.isSuccess && !!targetOrgId,
|
||||
retry: false,
|
||||
})
|
||||
@@ -78,12 +78,7 @@ export function StoragePage() {
|
||||
const hasActivePlan = Boolean(
|
||||
quotaQuery.data?.currentPlan || quotaQuery.data?.storagePlanName || quotaQuery.data?.trafficPlanName,
|
||||
)
|
||||
const wallet = walletQuery.data
|
||||
? {
|
||||
balance: walletQuery.data.items[0]?.availableAmount ?? 0,
|
||||
currency: walletQuery.data.items[0]?.currency ?? 'usd',
|
||||
}
|
||||
: undefined
|
||||
const credits = creditsQuery.data ? { balance: creditsQuery.data.balance } : undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (deliveredCheckoutCount > 0) queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
|
||||
@@ -94,7 +89,7 @@ export function StoragePage() {
|
||||
const interval = window.setInterval(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', 'quota'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'wallet'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
}, 5000)
|
||||
const timeout = window.setTimeout(() => setCheckoutRefreshActive(false), 120000)
|
||||
return () => {
|
||||
@@ -108,7 +103,7 @@ export function StoragePage() {
|
||||
onSuccess: () => {
|
||||
toast.success(t('storage.cancelSuccess'))
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'orders'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'wallet'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
@@ -120,11 +115,10 @@ export function StoragePage() {
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
t('storage.redeemSuccess', {
|
||||
amount: result.redeemedAmount / 100,
|
||||
currency: result.currency?.toUpperCase() ?? 'USD',
|
||||
amount: result.redeemedCredits,
|
||||
}),
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'wallet'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['cloud-store', 'credits'] })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
@@ -178,10 +172,10 @@ export function StoragePage() {
|
||||
<p className="text-sm text-muted-foreground">{t('storage.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<WalletBalanceButton
|
||||
wallet={wallet}
|
||||
transactions={walletTransactionsQuery.data?.items ?? []}
|
||||
loading={walletTransactionsQuery.isLoading}
|
||||
<CreditBalanceButton
|
||||
credits={credits}
|
||||
entries={creditLedgerQuery.data?.items ?? []}
|
||||
loading={creditLedgerQuery.isLoading}
|
||||
onRedeem={(code) => redeemMutation.mutate(code)}
|
||||
isRedeeming={redeemMutation.isPending}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user