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:
@@ -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(),
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -489,8 +489,7 @@
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"methodLabels": {
|
||||
"stripe": "Credit Card",
|
||||
"eft": "EFT/Wire"
|
||||
"stripe": "Credit Card"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Current Plan",
|
||||
|
||||
@@ -489,8 +489,7 @@
|
||||
"refunded": "İade"
|
||||
},
|
||||
"methodLabels": {
|
||||
"stripe": "Kredi Kartı",
|
||||
"eft": "EFT/Havale"
|
||||
"stripe": "Kredi Kartı"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Mevcut Plan",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ERROR_CODES = {
|
||||
|
||||
// Payment
|
||||
PAYMENT_FAILED: "PAY_001",
|
||||
EFT_RECEIPT_REQUIRED: "PAY_003",
|
||||
PAYMENT_ALREADY_PROCESSED: "PAY_004",
|
||||
|
||||
// General
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user