feat(payments): remove EFT/havale, Stripe-only checkout + Turkish locale

EFT/Havale was retired; Stripe is now the sole payment method. Remove the
EFT code surface (shared PaymentMethod "eft" + EftPaymentInput + eftReceiptUrl,
EFT_RECEIPT_REQUIRED error code, billing UI receipt/filter/label paths,
payments.service eft read paths). DB columns (eft_receipt_url, bank_account_id,
bank_accounts) are kept and marked @deprecated to preserve historical records
and avoid a destructive migration — same pattern as the retired iyzico column.

Faz 3 conversion lever: set locale "tr" on the Stripe Checkout session. The
audience is Turkish B2B and ~60% of sessions reached the foreign-language
hosted page but never started a payment intent (pure abandonment, not decline).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:17:10 +03:00
parent d6c88ede73
commit 11975d8b5a
10 changed files with 29 additions and 44 deletions

View File

@@ -184,7 +184,9 @@ export const userBrands = pgTable(
],
);
// ─── Bank Accounts (EFT/Havale destination accounts, one active at a time) ────
// ─── Bank Accounts ───────────────────────────────────
// @deprecated EFT/Havale was retired (Stripe is the sole payment method). Table
// kept only to preserve historical FK integrity; no rows are created anymore.
export const bankAccounts = pgTable(
"bank_accounts",
{
@@ -225,7 +227,9 @@ export const payments = pgTable(
iyzicoPaymentId: text("iyzico_payment_id"),
stripeSessionId: text("stripe_session_id"),
stripePaymentIntentId: text("stripe_payment_intent_id"),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
bankAccountId: uuid("bank_account_id").references(() => bankAccounts.id),
/** @deprecated EFT/Havale retired (Stripe-only). Kept for historical data. */
eftReceiptUrl: text("eft_receipt_url"),
adminNote: text("admin_note"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),

View File

@@ -16,9 +16,9 @@ export class PaymentsService {
/**
* Payment history for the billing page. Returns a curated projection — never
* the raw row — so internal columns (adminNote, iyzicoPaymentId,
* bankAccountId, session/intent ids) are not leaked to the client. planName
* is joined from the subscription's plan; Stripe receipt availability is
* surfaced as a boolean rather than exposing the payment intent id.
* session/intent ids) are not leaked to the client. planName is joined from
* the subscription's plan; Stripe receipt availability is surfaced as a
* boolean rather than exposing the payment intent id.
*/
async getMyPayments(userId: string) {
const rows = await this.db
@@ -29,7 +29,6 @@ export class PaymentsService {
status: payments.status,
createdAt: payments.createdAt,
planName: plans.name,
eftReceiptUrl: payments.eftReceiptUrl,
stripePaymentIntentId: payments.stripePaymentIntentId,
})
.from(payments)
@@ -45,16 +44,15 @@ export class PaymentsService {
status: row.status,
createdAt: row.createdAt,
planName: row.planName ?? null,
eftReceiptUrl: row.eftReceiptUrl ?? null,
hasStripeReceipt:
row.method === "stripe" && row.status === "completed" && !!row.stripePaymentIntentId,
}));
}
/**
* Resolve a receipt URL for one of the current user's payments.
* EFT receipts are stored locally; Stripe receipts are fetched live from the
* payment intent's latest charge. Returns { url: null } when none exists.
* Resolve a receipt URL for one of the current user's payments. Stripe is the
* sole payment method; receipts are fetched live from the payment intent's
* latest charge. Returns { url: null } when none exists.
*/
async getReceiptUrl(userId: string, paymentId: string): Promise<{ url: string | null }> {
const [payment] = await this.db
@@ -67,10 +65,6 @@ export class PaymentsService {
throw new NotFoundException("Payment not found");
}
if (payment.eftReceiptUrl) {
return { url: payment.eftReceiptUrl };
}
if (payment.method === "stripe" && payment.stripePaymentIntentId) {
return { url: await this.stripeService.getReceiptUrl(payment.stripePaymentIntentId) };
}

View File

@@ -143,6 +143,10 @@ export class StripeService {
const session = await this.stripe.checkout.sessions.create({
mode: "payment",
// Render Stripe's hosted page in Turkish. The audience is Turkish B2B; a
// foreign-language checkout is a known abandonment driver (~60% of sessions
// reached the page but never started a payment intent).
locale: "tr",
payment_method_types: ["card"],
customer_email: userEmail,
line_items: [

View File

@@ -489,8 +489,7 @@
"refunded": "Refunded"
},
"methodLabels": {
"stripe": "Credit Card",
"eft": "EFT/Wire"
"stripe": "Credit Card"
},
"summary": {
"title": "Current Plan",

View File

@@ -489,8 +489,7 @@
"refunded": "İade"
},
"methodLabels": {
"stripe": "Kredi Kartı",
"eft": "EFT/Havale"
"stripe": "Kredi Kartı"
},
"summary": {
"title": "Mevcut Plan",

View File

@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Download, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
import { ArrowRight, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/billing")({
@@ -26,10 +26,11 @@ interface Subscription {
interface Payment {
id: string;
amount: number;
method: "stripe" | "eft";
// Stripe is the sole method; kept as string so retired historical values
// (iyzico/eft) still render in the history table.
method: string;
status: "completed" | "pending" | "failed" | "refunded";
planName?: string | null;
eftReceiptUrl?: string | null;
hasStripeReceipt?: boolean;
createdAt: string;
}
@@ -213,7 +214,7 @@ function BillingPage() {
onChange={setMethodFilter}
options={[
{ value: "all", label: t("common.all") },
...["stripe", "eft"].map((m) => ({
...["stripe"].map((m) => ({
value: m,
label: t(`billing.methodLabels.${m}`),
})),
@@ -309,7 +310,9 @@ function BillingPage() {
</td>
<td data-label={t("billing.method")} className={`${TD} sm:text-center`}>
<Badge variant={payment.method === "stripe" ? "secondary" : "outline"}>
{t(`billing.methodLabels.${payment.method}`)}
{payment.method === "stripe"
? t("billing.methodLabels.stripe")
: payment.method.toUpperCase()}
</Badge>
</td>
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
@@ -329,19 +332,7 @@ function BillingPage() {
</span>
</td>
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
{payment.eftReceiptUrl ? (
<Button variant="outline" size="sm" asChild>
<a
href={payment.eftReceiptUrl}
target="_blank"
rel="noopener noreferrer"
download
>
<Download className="mr-1 h-3 w-3" />
{t("billing.downloadReceipt")}
</a>
</Button>
) : payment.hasStripeReceipt ? (
{payment.hasStripeReceipt ? (
<Button
variant="outline"
size="sm"

View File

@@ -19,7 +19,6 @@ export const ERROR_CODES = {
// Payment
PAYMENT_FAILED: "PAY_001",
EFT_RECEIPT_REQUIRED: "PAY_003",
PAYMENT_ALREADY_PROCESSED: "PAY_004",
// General

View File

@@ -896,7 +896,6 @@ describe("ERROR_CODES", () => {
it("has payment error codes", () => {
expect(ERROR_CODES.PAYMENT_FAILED).toBe("PAY_001");
expect(ERROR_CODES.EFT_RECEIPT_REQUIRED).toBe("PAY_003");
expect(ERROR_CODES.PAYMENT_ALREADY_PROCESSED).toBe("PAY_004");
});

View File

@@ -15,7 +15,6 @@ export type {
Payment,
PaymentMethod,
PaymentStatus,
EftPaymentInput,
} from "./types/payment.js";
export type { ApiResponse, ApiError, PaginationMeta } from "./types/api-response.js";
export type { PaginationInput, PaginatedResult } from "./types/pagination.js";

View File

@@ -6,15 +6,12 @@ export interface Payment {
currency: string;
method: PaymentMethod;
status: PaymentStatus;
eftReceiptUrl: string | null;
adminNote: string | null;
createdAt: Date;
updatedAt: Date;
}
export type PaymentMethod = "stripe" | "eft";
// Stripe is the sole payment method. "iyzico"/"eft" are retired methods kept
// only as historical values in the DB `method` column — never offered to users.
export type PaymentMethod = "stripe";
export type PaymentStatus = "pending" | "completed" | "failed" | "refunded";
export interface EftPaymentInput {
subscriptionId: string;
}