feat(tip): support tiered coffee gifts
This commit is contained in:
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import {
|
||||
resolveExternalEntryDestination,
|
||||
resolveExternalEntryPromotionType,
|
||||
resolveExternalEntryTarget,
|
||||
shouldBindExternalFacebookIdentity,
|
||||
@@ -30,6 +31,30 @@ describe("external entry navigation", () => {
|
||||
expect(resolveExternalEntryTarget({ target: "/tip" })).toBe(ROUTES.chat);
|
||||
expect(resolveExternalEntryTarget({ target: "sidebar" })).toBe(ROUTES.chat);
|
||||
});
|
||||
|
||||
it("routes tip entries to the requested coffee type", () => {
|
||||
expect(
|
||||
resolveExternalEntryDestination({
|
||||
target: "tip",
|
||||
coffeeType: "medium",
|
||||
}),
|
||||
).toBe("/tip?coffee_type=medium");
|
||||
expect(
|
||||
resolveExternalEntryDestination({
|
||||
target: "tip",
|
||||
coffeeType: "large",
|
||||
}),
|
||||
).toBe("/tip?coffee_type=large");
|
||||
});
|
||||
|
||||
it("defaults invalid tip coffee types to small", () => {
|
||||
expect(
|
||||
resolveExternalEntryDestination({
|
||||
target: "tip",
|
||||
coffeeType: "unknown",
|
||||
}),
|
||||
).toBe("/tip?coffee_type=small");
|
||||
});
|
||||
});
|
||||
|
||||
describe("external entry facebook identity binding", () => {
|
||||
|
||||
@@ -5,6 +5,11 @@ import { UserStorage } from "@/data/storage/user/user_storage";
|
||||
import type { PendingChatPromotionType } from "@/data/storage/navigation";
|
||||
import type { LoginStatus } from "@/data/dto/auth";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import {
|
||||
buildTipCoffeePath,
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
resolveTipCoffeeType,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
|
||||
export type ExternalEntryTarget =
|
||||
| typeof ROUTES.chat
|
||||
@@ -22,6 +27,10 @@ export interface ExternalEntryTargetInput {
|
||||
target?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalEntryDestinationInput extends ExternalEntryTargetInput {
|
||||
coffeeType?: string | null;
|
||||
}
|
||||
|
||||
export interface ExternalEntryPromotionInput {
|
||||
mode?: string | null;
|
||||
promotionType?: string | null;
|
||||
@@ -77,6 +86,18 @@ export function resolveExternalEntryTarget({
|
||||
return resolveTarget(target) ?? ROUTES.chat;
|
||||
}
|
||||
|
||||
export function resolveExternalEntryDestination({
|
||||
target,
|
||||
coffeeType,
|
||||
}: ExternalEntryDestinationInput): string {
|
||||
const resolvedTarget = resolveExternalEntryTarget({ target });
|
||||
if (resolvedTarget !== ROUTES.tip) return resolvedTarget;
|
||||
|
||||
return buildTipCoffeePath(
|
||||
resolveTipCoffeeType(coffeeType) ?? DEFAULT_TIP_COFFEE_TYPE,
|
||||
);
|
||||
}
|
||||
|
||||
export async function persistExternalEntryPayload({
|
||||
deviceId,
|
||||
asid,
|
||||
|
||||
@@ -18,7 +18,17 @@ describe("pending payment order helpers", () => {
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
tipCoffeeType: "large",
|
||||
}),
|
||||
).toBe("/tip?payChannel=ezpay&paymentReturn=1");
|
||||
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=large");
|
||||
});
|
||||
|
||||
it("defaults legacy tip payment returns to small coffee", () => {
|
||||
expect(
|
||||
buildPendingPaymentSubscriptionUrl({
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: "tip",
|
||||
}),
|
||||
).toBe("/tip?payChannel=ezpay&paymentReturn=1&coffee_type=small");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
savePendingEzpayOrder,
|
||||
type PendingPaymentReturnTo,
|
||||
type PendingPaymentSubscriptionType,
|
||||
type PendingPaymentTipCoffeeType,
|
||||
} from "./pending_payment_order";
|
||||
|
||||
const log = new Logger("LibPaymentPaymentLaunch");
|
||||
@@ -61,6 +62,7 @@ export interface LaunchEzpayRedirectInput {
|
||||
orderId: string | null;
|
||||
paymentUrl: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
onFailed: (errorMessage: string) => void;
|
||||
}
|
||||
@@ -69,6 +71,7 @@ export async function launchEzpayRedirect({
|
||||
orderId,
|
||||
paymentUrl,
|
||||
subscriptionType,
|
||||
tipCoffeeType,
|
||||
returnTo,
|
||||
onFailed,
|
||||
}: LaunchEzpayRedirectInput): Promise<void> {
|
||||
@@ -93,6 +96,7 @@ export async function launchEzpayRedirect({
|
||||
const saveResult = await savePendingEzpayOrder({
|
||||
orderId,
|
||||
subscriptionType,
|
||||
...(tipCoffeeType ? { tipCoffeeType } : {}),
|
||||
...(returnTo ? { returnTo } : {}),
|
||||
});
|
||||
if (Result.isErr(saveResult)) {
|
||||
|
||||
@@ -5,15 +5,23 @@ import {
|
||||
type PendingPaymentOrder,
|
||||
} from "@/data/storage";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import {
|
||||
DEFAULT_TIP_COFFEE_TYPE,
|
||||
TIP_COFFEE_TYPE_PARAM,
|
||||
} from "@/lib/tip/tip_coffee";
|
||||
import type { Result } from "@/utils";
|
||||
|
||||
export type PendingPaymentSubscriptionType =
|
||||
PendingPaymentOrder["subscriptionType"];
|
||||
export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"];
|
||||
export type PendingPaymentTipCoffeeType = NonNullable<
|
||||
PendingPaymentOrder["tipCoffeeType"]
|
||||
>;
|
||||
|
||||
export function savePendingEzpayOrder(input: {
|
||||
orderId: string;
|
||||
subscriptionType: PendingPaymentSubscriptionType;
|
||||
tipCoffeeType?: PendingPaymentTipCoffeeType;
|
||||
returnTo?: PendingPaymentReturnTo;
|
||||
createdAt?: number;
|
||||
}): Promise<Result<void>> {
|
||||
@@ -21,6 +29,7 @@ export function savePendingEzpayOrder(input: {
|
||||
orderId: input.orderId,
|
||||
payChannel: "ezpay",
|
||||
subscriptionType: input.subscriptionType,
|
||||
...(input.tipCoffeeType ? { tipCoffeeType: input.tipCoffeeType } : {}),
|
||||
...(input.returnTo ? { returnTo: input.returnTo } : {}),
|
||||
createdAt: input.createdAt ?? Date.now(),
|
||||
});
|
||||
@@ -45,13 +54,15 @@ export function clearPendingPaymentOrder(): Promise<Result<void>> {
|
||||
export function buildPendingPaymentSubscriptionUrl(
|
||||
order: Pick<
|
||||
PendingPaymentOrder,
|
||||
"payChannel" | "returnTo" | "subscriptionType"
|
||||
"payChannel" | "returnTo" | "subscriptionType" | "tipCoffeeType"
|
||||
>,
|
||||
): string {
|
||||
if (order.subscriptionType === "tip") {
|
||||
const params = new URLSearchParams({
|
||||
payChannel: order.payChannel,
|
||||
paymentReturn: "1",
|
||||
[TIP_COFFEE_TYPE_PARAM]:
|
||||
order.tipCoffeeType ?? DEFAULT_TIP_COFFEE_TYPE,
|
||||
});
|
||||
return `${ROUTES.tip}?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildTipCoffeePath,
|
||||
getTipCoffeeOption,
|
||||
resolveTipCoffeeType,
|
||||
} from "../tip_coffee";
|
||||
|
||||
describe("tip coffee configuration", () => {
|
||||
it("resolves supported types case-insensitively", () => {
|
||||
expect(resolveTipCoffeeType(" Small ")).toBe("small");
|
||||
expect(resolveTipCoffeeType("MEDIUM")).toBe("medium");
|
||||
expect(resolveTipCoffeeType("large")).toBe("large");
|
||||
});
|
||||
|
||||
it("rejects missing and unsupported types", () => {
|
||||
expect(resolveTipCoffeeType(null)).toBeNull();
|
||||
expect(resolveTipCoffeeType("espresso")).toBeNull();
|
||||
});
|
||||
|
||||
it("provides the configured plan and fallback price for each type", () => {
|
||||
expect(getTipCoffeeOption("small")).toMatchObject({
|
||||
amountCents: 499,
|
||||
planId: "tip_coffee_small",
|
||||
});
|
||||
expect(getTipCoffeeOption("medium")).toMatchObject({
|
||||
amountCents: 999,
|
||||
planId: "tip_coffee_medium",
|
||||
});
|
||||
expect(getTipCoffeeOption("large")).toMatchObject({
|
||||
amountCents: 1999,
|
||||
planId: "tip_coffee_large",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds canonical tip paths", () => {
|
||||
expect(buildTipCoffeePath("small")).toBe("/tip?coffee_type=small");
|
||||
expect(buildTipCoffeePath("medium")).toBe("/tip?coffee_type=medium");
|
||||
expect(buildTipCoffeePath("large")).toBe("/tip?coffee_type=large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
export const TIP_COFFEE_TYPE_PARAM = "coffee_type";
|
||||
export const DEFAULT_TIP_COFFEE_TYPE = "small";
|
||||
|
||||
export type TipCoffeeType = "small" | "medium" | "large";
|
||||
|
||||
export interface TipCoffeeOption {
|
||||
type: TipCoffeeType;
|
||||
amountCents: number;
|
||||
fallbackName: string;
|
||||
planId: string;
|
||||
}
|
||||
|
||||
const TIP_COFFEE_OPTIONS: Record<TipCoffeeType, TipCoffeeOption> = {
|
||||
small: {
|
||||
type: "small",
|
||||
amountCents: 499,
|
||||
fallbackName: "Small Coffee",
|
||||
planId: "tip_coffee_small",
|
||||
},
|
||||
medium: {
|
||||
type: "medium",
|
||||
amountCents: 999,
|
||||
fallbackName: "Medium Coffee",
|
||||
planId: "tip_coffee_medium",
|
||||
},
|
||||
large: {
|
||||
type: "large",
|
||||
amountCents: 1999,
|
||||
fallbackName: "Large Coffee",
|
||||
planId: "tip_coffee_large",
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveTipCoffeeType(
|
||||
value: string | null | undefined,
|
||||
): TipCoffeeType | null {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (
|
||||
normalized === "small" ||
|
||||
normalized === "medium" ||
|
||||
normalized === "large"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getTipCoffeeOption(type: TipCoffeeType): TipCoffeeOption {
|
||||
return TIP_COFFEE_OPTIONS[type];
|
||||
}
|
||||
|
||||
export function buildTipCoffeePath(type: TipCoffeeType): string {
|
||||
const params = new URLSearchParams({ [TIP_COFFEE_TYPE_PARAM]: type });
|
||||
return `${ROUTES.tip}?${params.toString()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user