@softure-ai/billing 0.0.0-stage → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +699 -2
- package/dist/calendar.d.ts +26 -0
- package/dist/calendar.d.ts.map +1 -0
- package/dist/calendar.js +81 -0
- package/dist/calendar.js.map +1 -0
- package/dist/contract.d.ts +187 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +6 -0
- package/dist/contract.js.map +1 -0
- package/dist/currency-digits.d.ts +3 -0
- package/dist/currency-digits.d.ts.map +1 -0
- package/dist/currency-digits.js +32 -0
- package/dist/currency-digits.js.map +1 -0
- package/dist/entitlement.d.ts +25 -0
- package/dist/entitlement.d.ts.map +1 -0
- package/dist/entitlement.js +75 -0
- package/dist/entitlement.js.map +1 -0
- package/dist/fields.d.ts +25 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +27 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.d.ts +274 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/invoice.d.ts +29 -0
- package/dist/invoice.d.ts.map +1 -0
- package/dist/invoice.js +35 -0
- package/dist/invoice.js.map +1 -0
- package/dist/mailing/index.d.ts +2 -0
- package/dist/mailing/index.d.ts.map +1 -0
- package/dist/mailing/index.js +4 -0
- package/dist/mailing/index.js.map +1 -0
- package/dist/mailing/reminder-mail.d.ts +50 -0
- package/dist/mailing/reminder-mail.d.ts.map +1 -0
- package/dist/mailing/reminder-mail.js +71 -0
- package/dist/mailing/reminder-mail.js.map +1 -0
- package/dist/manual.d.ts +12 -0
- package/dist/manual.d.ts.map +1 -0
- package/dist/manual.js +22 -0
- package/dist/manual.js.map +1 -0
- package/dist/messages/en.d.ts +176 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +151 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +359 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +14 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +151 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/access.d.ts +16 -0
- package/dist/next/access.d.ts.map +1 -0
- package/dist/next/access.js +24 -0
- package/dist/next/access.js.map +1 -0
- package/dist/next/actions.d.ts +24 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +158 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +17 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/current-entitlement.d.ts +18 -0
- package/dist/next/current-entitlement.d.ts.map +1 -0
- package/dist/next/current-entitlement.js +26 -0
- package/dist/next/current-entitlement.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +12 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/pages.d.ts +24 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +166 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/pricing.d.ts +12 -0
- package/dist/next/pricing.d.ts.map +1 -0
- package/dist/next/pricing.js +20 -0
- package/dist/next/pricing.js.map +1 -0
- package/dist/next/route.d.ts +11 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +56 -0
- package/dist/next/route.js.map +1 -0
- package/dist/options.d.ts +85 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +121 -0
- package/dist/options.js.map +1 -0
- package/dist/payment.d.ts +61 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +12 -0
- package/dist/payment.js.map +1 -0
- package/dist/plans.d.ts +20 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +53 -0
- package/dist/plans.js.map +1 -0
- package/dist/price.d.ts +12 -0
- package/dist/price.d.ts.map +1 -0
- package/dist/price.js +37 -0
- package/dist/price.js.map +1 -0
- package/dist/refund.d.ts +70 -0
- package/dist/refund.d.ts.map +1 -0
- package/dist/refund.js +109 -0
- package/dist/refund.js.map +1 -0
- package/dist/reminder.d.ts +30 -0
- package/dist/reminder.d.ts.map +1 -0
- package/dist/reminder.js +34 -0
- package/dist/reminder.js.map +1 -0
- package/dist/schema.d.ts +1023 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +77 -0
- package/dist/schema.js.map +1 -0
- package/dist/scripts/entitlement-scripts.d.ts +17 -0
- package/dist/scripts/entitlement-scripts.d.ts.map +1 -0
- package/dist/scripts/entitlement-scripts.js +167 -0
- package/dist/scripts/entitlement-scripts.js.map +1 -0
- package/dist/scripts/index.d.ts +3 -0
- package/dist/scripts/index.d.ts.map +1 -0
- package/dist/scripts/index.js +5 -0
- package/dist/scripts/index.js.map +1 -0
- package/dist/scripts/plan-scripts.d.ts +19 -0
- package/dist/scripts/plan-scripts.d.ts.map +1 -0
- package/dist/scripts/plan-scripts.js +106 -0
- package/dist/scripts/plan-scripts.js.map +1 -0
- package/dist/server/entitlements.d.ts +69 -0
- package/dist/server/entitlements.d.ts.map +1 -0
- package/dist/server/entitlements.js +202 -0
- package/dist/server/entitlements.js.map +1 -0
- package/dist/server/grants.d.ts +74 -0
- package/dist/server/grants.d.ts.map +1 -0
- package/dist/server/grants.js +174 -0
- package/dist/server/grants.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +17 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/options.d.ts +20 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +38 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/payments.d.ts +140 -0
- package/dist/server/payments.d.ts.map +1 -0
- package/dist/server/payments.js +339 -0
- package/dist/server/payments.js.map +1 -0
- package/dist/server/plans.d.ts +50 -0
- package/dist/server/plans.d.ts.map +1 -0
- package/dist/server/plans.js +129 -0
- package/dist/server/plans.js.map +1 -0
- package/dist/server/privacy.d.ts +77 -0
- package/dist/server/privacy.d.ts.map +1 -0
- package/dist/server/privacy.js +110 -0
- package/dist/server/privacy.js.map +1 -0
- package/dist/server/reminders.d.ts +20 -0
- package/dist/server/reminders.d.ts.map +1 -0
- package/dist/server/reminders.js +85 -0
- package/dist/server/reminders.js.map +1 -0
- package/dist/server/requests.d.ts +80 -0
- package/dist/server/requests.d.ts.map +1 -0
- package/dist/server/requests.js +155 -0
- package/dist/server/requests.js.map +1 -0
- package/dist/server/setup.d.ts +9 -0
- package/dist/server/setup.d.ts.map +1 -0
- package/dist/server/setup.js +34 -0
- package/dist/server/setup.js.map +1 -0
- package/dist/server/take-back.d.ts +80 -0
- package/dist/server/take-back.d.ts.map +1 -0
- package/dist/server/take-back.js +138 -0
- package/dist/server/take-back.js.map +1 -0
- package/dist/server/user-id.d.ts +4 -0
- package/dist/server/user-id.d.ts.map +1 -0
- package/dist/server/user-id.js +11 -0
- package/dist/server/user-id.js.map +1 -0
- package/dist/stripe-currency.d.ts +27 -0
- package/dist/stripe-currency.d.ts.map +1 -0
- package/dist/stripe-currency.js +57 -0
- package/dist/stripe-currency.js.map +1 -0
- package/dist/stripe-webhook.d.ts +101 -0
- package/dist/stripe-webhook.d.ts.map +1 -0
- package/dist/stripe-webhook.js +209 -0
- package/dist/stripe-webhook.js.map +1 -0
- package/dist/stripe.d.ts +25 -0
- package/dist/stripe.d.ts.map +1 -0
- package/dist/stripe.js +116 -0
- package/dist/stripe.js.map +1 -0
- package/dist/ui/access-badge.d.ts +16 -0
- package/dist/ui/access-badge.d.ts.map +1 -0
- package/dist/ui/access-badge.js +43 -0
- package/dist/ui/access-badge.js.map +1 -0
- package/dist/ui/access-notice.d.ts +20 -0
- package/dist/ui/access-notice.d.ts.map +1 -0
- package/dist/ui/access-notice.js +41 -0
- package/dist/ui/access-notice.js.map +1 -0
- package/dist/ui/format.d.ts +11 -0
- package/dist/ui/format.d.ts.map +1 -0
- package/dist/ui/format.js +21 -0
- package/dist/ui/format.js.map +1 -0
- package/dist/ui/grant-form.d.ts +18 -0
- package/dist/ui/grant-form.d.ts.map +1 -0
- package/dist/ui/grant-form.js +23 -0
- package/dist/ui/grant-form.js.map +1 -0
- package/dist/ui/grant-history.d.ts +39 -0
- package/dist/ui/grant-history.d.ts.map +1 -0
- package/dist/ui/grant-history.js +36 -0
- package/dist/ui/grant-history.js.map +1 -0
- package/dist/ui/index.d.ts +9 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +12 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/payment-form.d.ts +23 -0
- package/dist/ui/payment-form.d.ts.map +1 -0
- package/dist/ui/payment-form.js +34 -0
- package/dist/ui/payment-form.js.map +1 -0
- package/dist/ui/payment-requests.d.ts +30 -0
- package/dist/ui/payment-requests.d.ts.map +1 -0
- package/dist/ui/payment-requests.js +31 -0
- package/dist/ui/payment-requests.js.map +1 -0
- package/dist/ui/pricing-tiles.d.ts +22 -0
- package/dist/ui/pricing-tiles.d.ts.map +1 -0
- package/dist/ui/pricing-tiles.js +40 -0
- package/dist/ui/pricing-tiles.js.map +1 -0
- package/migrations/0001_create_entitlements.sql +16 -0
- package/migrations/0002_create_payments.sql +30 -0
- package/migrations/0003_record_payment_grants.sql +25 -0
- package/migrations/0004_create_requests_and_grants.sql +58 -0
- package/migrations/0005_record_refunded_amounts.sql +18 -0
- package/migrations/0006_record_request_handover_and_prices.sql +38 -0
- package/migrations/0007_record_failed_refunds.sql +27 -0
- package/migrations/0008_record_request_handover_claims.sql +9 -0
- package/migrations/0009_record_pending_charge_states.sql +16 -0
- package/module.json +23 -0
- package/package.json +81 -4
- package/src/calendar.ts +90 -0
- package/src/contract.ts +181 -0
- package/src/currency-digits.ts +37 -0
- package/src/entitlement.ts +84 -0
- package/src/fields.ts +37 -0
- package/src/index.ts +163 -0
- package/src/invoice.ts +58 -0
- package/src/mailing/index.ts +11 -0
- package/src/mailing/reminder-mail.ts +108 -0
- package/src/manual.ts +31 -0
- package/src/messages/en.ts +150 -0
- package/src/messages/index.ts +18 -0
- package/src/messages/pl.ts +152 -0
- package/src/next/access.tsx +55 -0
- package/src/next/actions.ts +176 -0
- package/src/next/context.ts +18 -0
- package/src/next/current-entitlement.ts +36 -0
- package/src/next/index.ts +11 -0
- package/src/next/next-modules.d.ts +21 -0
- package/src/next/pages.tsx +267 -0
- package/src/next/pricing.tsx +39 -0
- package/src/next/route.ts +57 -0
- package/src/options.ts +128 -0
- package/src/payment.ts +77 -0
- package/src/plans.ts +63 -0
- package/src/price.ts +50 -0
- package/src/refund.ts +135 -0
- package/src/reminder.ts +52 -0
- package/src/schema.ts +86 -0
- package/src/scripts/entitlement-scripts.ts +188 -0
- package/src/scripts/index.ts +11 -0
- package/src/scripts/plan-scripts.ts +143 -0
- package/src/server/entitlements.ts +227 -0
- package/src/server/grants.ts +227 -0
- package/src/server/health.ts +18 -0
- package/src/server/index.ts +83 -0
- package/src/server/options.ts +52 -0
- package/src/server/payments.ts +434 -0
- package/src/server/plans.ts +143 -0
- package/src/server/privacy.ts +189 -0
- package/src/server/reminders.ts +113 -0
- package/src/server/requests.ts +201 -0
- package/src/server/setup.ts +37 -0
- package/src/server/take-back.ts +190 -0
- package/src/server/user-id.ts +12 -0
- package/src/stripe-currency.ts +65 -0
- package/src/stripe-webhook.ts +278 -0
- package/src/stripe.ts +130 -0
- package/src/ui/access-badge.tsx +64 -0
- package/src/ui/access-notice.tsx +73 -0
- package/src/ui/format.ts +25 -0
- package/src/ui/grant-form.tsx +69 -0
- package/src/ui/grant-history.tsx +127 -0
- package/src/ui/index.ts +25 -0
- package/src/ui/payment-form.tsx +111 -0
- package/src/ui/payment-requests.tsx +126 -0
- package/src/ui/pricing-tiles.tsx +105 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Taking back what one grant added, shared by a provider refund (`refundPayment`) and an admin's
|
|
2
|
+
// revoke (`revokeManualGrant`): a period loses its unused days (a partial refund only its share of
|
|
3
|
+
// them) and every period stored after it moves back by as many days, in both tables (provider
|
|
4
|
+
// payments and manual grants), so their stored dates keep saying where their access lies; a
|
|
5
|
+
// lifetime ends unless something else still pays for it (a partial refund never ends it). Lock order: the caller takes the account (key share), then the entitlement
|
|
6
|
+
// (`lockEntitlementRow`), then flips its own row; the shift then updates other rows under the
|
|
7
|
+
// entitlement lock, so two take-backs of one account never wait on each other's rows. A refund that
|
|
8
|
+
// fails later gives days back (`giveBackDays`) the same way in reverse.
|
|
9
|
+
import type { Queryable } from "@softure-ai/db";
|
|
10
|
+
import { and, eq, gte, ne, type SQL } from "drizzle-orm";
|
|
11
|
+
import type { Entitlement, EntitlementEvent, EntitlementRecord, PaymentGrant } from "../contract.js";
|
|
12
|
+
import { resolveEntitlement } from "../entitlement.js";
|
|
13
|
+
import { getGrantStart, getRefundEvent, getTakenBackDays, isFullShare, moveBackByDays, moveForwardByDays, type RefundShare } from "../refund.js";
|
|
14
|
+
import { entitlements, manualGrants, payments } from "../schema.js";
|
|
15
|
+
import { changeEntitlement, findEntitlementRecord, type BillingContext } from "./entitlements.js";
|
|
16
|
+
import { getEntitlementPolicy } from "./options.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Locks the account's entitlement row until the transaction ends (nothing when it has none yet:
|
|
20
|
+
* a check that must hold before the first change pins the row first, `pinEntitlementRow`).
|
|
21
|
+
* Taken before a refund's or a revoke's own row, so every take-back of the account queues here.
|
|
22
|
+
*/
|
|
23
|
+
export async function lockEntitlementRow(tx: Queryable, userId: string): Promise<void> {
|
|
24
|
+
await tx.select({ userId: entitlements.userId }).from(entitlements).where(eq(entitlements.userId, userId)).for("update");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a paid payment of the account (other than `exceptId`) bought lifetime access. */
|
|
28
|
+
export async function hasPaidLifetimePayment(tx: Queryable, userId: string, exceptId?: string): Promise<boolean> {
|
|
29
|
+
const conditions: SQL[] = [eq(payments.userId, userId), eq(payments.grantKind, "lifetime"), eq(payments.status, "paid")];
|
|
30
|
+
if (exceptId !== undefined) conditions.push(ne(payments.id, exceptId));
|
|
31
|
+
const [other] = await tx.select({ id: payments.id }).from(payments).where(and(...conditions)).limit(1);
|
|
32
|
+
return other !== undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether an active manual grant of the account (other than `exceptId`) gave lifetime access. */
|
|
36
|
+
export async function hasActiveManualLifetime(tx: Queryable, userId: string, exceptId?: string): Promise<boolean> {
|
|
37
|
+
const conditions: SQL[] = [eq(manualGrants.userId, userId), eq(manualGrants.grantKind, "lifetime"), eq(manualGrants.status, "active")];
|
|
38
|
+
if (exceptId !== undefined) conditions.push(ne(manualGrants.id, exceptId));
|
|
39
|
+
const [other] = await tx.select({ id: manualGrants.id }).from(manualGrants).where(and(...conditions)).limit(1);
|
|
40
|
+
return other !== undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ShiftLaterPeriodsInput {
|
|
44
|
+
readonly userId: string;
|
|
45
|
+
/** Stored periods starting at or after this instant move (the end of the period taken back or given back). */
|
|
46
|
+
readonly after: Date;
|
|
47
|
+
readonly days: number;
|
|
48
|
+
/** `back` when days are taken back, `forward` when a failed refund gives them back. */
|
|
49
|
+
readonly direction: "back" | "forward";
|
|
50
|
+
readonly timezone: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface StoredPeriod {
|
|
54
|
+
readonly id: string;
|
|
55
|
+
readonly grantedFrom: Date | null;
|
|
56
|
+
readonly grantedUntil: Date | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The stored period moved by `days` local days in the input's direction. */
|
|
60
|
+
function getShiftedPeriod(row: StoredPeriod, input: ShiftLaterPeriodsInput): { grantedFrom: Date; grantedUntil: Date } | null {
|
|
61
|
+
if (row.grantedFrom === null || row.grantedUntil === null) return null;
|
|
62
|
+
const days = input.direction === "back" ? input.days : -input.days;
|
|
63
|
+
return { grantedFrom: moveBackByDays(row.grantedFrom, days, input.timezone), grantedUntil: moveBackByDays(row.grantedUntil, days, input.timezone) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Moves the stored periods that follow the one taken back or given back (paid provider payments and
|
|
68
|
+
* active manual grants starting at or after `after`) by the days, so a later refund or revoke of one
|
|
69
|
+
* of them takes back the right days. The caller holds the entitlement lock.
|
|
70
|
+
*/
|
|
71
|
+
export async function shiftLaterPeriods(tx: Queryable, input: ShiftLaterPeriodsInput): Promise<void> {
|
|
72
|
+
const laterPayments = await tx
|
|
73
|
+
.select({ id: payments.id, grantedFrom: payments.grantedFrom, grantedUntil: payments.grantedUntil })
|
|
74
|
+
.from(payments)
|
|
75
|
+
.where(and(eq(payments.userId, input.userId), eq(payments.grantKind, "period"), eq(payments.status, "paid"), gte(payments.grantedFrom, input.after)));
|
|
76
|
+
for (const row of laterPayments) {
|
|
77
|
+
const shifted = getShiftedPeriod(row, input);
|
|
78
|
+
if (shifted !== null) await tx.update(payments).set(shifted).where(eq(payments.id, row.id));
|
|
79
|
+
}
|
|
80
|
+
const laterGrants = await tx
|
|
81
|
+
.select({ id: manualGrants.id, grantedFrom: manualGrants.grantedFrom, grantedUntil: manualGrants.grantedUntil })
|
|
82
|
+
.from(manualGrants)
|
|
83
|
+
.where(and(eq(manualGrants.userId, input.userId), eq(manualGrants.grantKind, "period"), eq(manualGrants.status, "active"), gte(manualGrants.grantedFrom, input.after)));
|
|
84
|
+
for (const row of laterGrants) {
|
|
85
|
+
const shifted = getShiftedPeriod(row, input);
|
|
86
|
+
if (shifted !== null) await tx.update(manualGrants).set(shifted).where(eq(manualGrants.id, row.id));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TakeBackGrantInput {
|
|
91
|
+
readonly userId: string;
|
|
92
|
+
/** What the refunded payment or revoked grant added; null for a payment stored before grants were (revokes paid access). */
|
|
93
|
+
readonly grant: PaymentGrant | null;
|
|
94
|
+
readonly now: Date;
|
|
95
|
+
/** Whether something else still pays for lifetime access; asked under the entitlement lock. */
|
|
96
|
+
readonly hasOtherLifetime: () => Promise<boolean>;
|
|
97
|
+
/** The part of a payment a partial refund returns; omitted to take back the whole grant. */
|
|
98
|
+
readonly share?: RefundShare;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Where the account stands after a take-back, and the local days its period lost (0 when nothing moved). */
|
|
102
|
+
export interface TakeBack {
|
|
103
|
+
readonly entitlement: Entitlement;
|
|
104
|
+
readonly days: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The change taking back `grant` makes, decided under the entitlement lock, or null when it takes nothing back. */
|
|
108
|
+
async function getTakeBackEvent(ctx: BillingContext, input: TakeBackGrantInput, record: EntitlementRecord): Promise<EntitlementEvent | null> {
|
|
109
|
+
const { grant, share } = input;
|
|
110
|
+
// A partial refund of a payment without a recorded grant, or of a lifetime, takes nothing back.
|
|
111
|
+
if (!isFullShare(share) && grant?.kind !== "period") return null;
|
|
112
|
+
if (grant === null) return { type: "revoke" };
|
|
113
|
+
if (grant.kind === "lifetime" && (await input.hasOtherLifetime())) return null;
|
|
114
|
+
return getRefundEvent(record, grant, { now: input.now, timezone: ctx.config.timezone, share });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Takes back what one grant added (its share, for a partial refund), inside the caller's transaction
|
|
119
|
+
* (`ctx.db` is it), and returns where the account stands after and how many days its period lost.
|
|
120
|
+
* The caller locked the account and the entitlement (`lockEntitlementRow`) and flipped its row
|
|
121
|
+
* first: a lifetime granted at the same time is either committed and seen below, or waits for the
|
|
122
|
+
* entitlement lock and is applied after.
|
|
123
|
+
*/
|
|
124
|
+
export async function takeBackGrant(ctx: BillingContext, input: TakeBackGrantInput): Promise<TakeBack> {
|
|
125
|
+
const tx = ctx.db;
|
|
126
|
+
const record = await findEntitlementRecord(ctx, input.userId);
|
|
127
|
+
// The caller's row references the account, which its key share lock keeps.
|
|
128
|
+
if (record === null) throw new Error("@softure-ai/billing: a grant to take back has no account");
|
|
129
|
+
const event = await getTakeBackEvent(ctx, input, record);
|
|
130
|
+
if (event === null) return { entitlement: resolveEntitlement(record, input.now, getEntitlementPolicy(ctx.config)), days: 0 };
|
|
131
|
+
const { grant } = input;
|
|
132
|
+
const { timezone } = ctx.config;
|
|
133
|
+
let days = 0;
|
|
134
|
+
if (grant?.kind === "period") {
|
|
135
|
+
days = getTakenBackDays(grant, { now: input.now, timezone, share: input.share });
|
|
136
|
+
await shiftLaterPeriods(tx, { userId: input.userId, after: grant.until, days, direction: "back", timezone });
|
|
137
|
+
}
|
|
138
|
+
const changed = await changeEntitlement(ctx, input.userId, event);
|
|
139
|
+
// The account is locked and these events are never refused.
|
|
140
|
+
if (!changed.ok) throw new Error(`@softure-ai/billing: taking back a grant failed with ${changed.error}`);
|
|
141
|
+
return { entitlement: changed.value, days };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface GiveBackDaysInput {
|
|
145
|
+
readonly userId: string;
|
|
146
|
+
/** The period the refunded payment stores now. */
|
|
147
|
+
readonly grant: Extract<PaymentGrant, { kind: "period" }>;
|
|
148
|
+
/** Whether the payment was still paid (refunded in part) before the failure. */
|
|
149
|
+
readonly isPaid: boolean;
|
|
150
|
+
readonly days: number;
|
|
151
|
+
readonly now: Date;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Where the account stands after days were given back, and the period the payment pays for now. */
|
|
155
|
+
export interface GiveBack {
|
|
156
|
+
readonly entitlement: Entitlement;
|
|
157
|
+
readonly period: Extract<PaymentGrant, { kind: "period" }>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gives back `days` local days a failed refund had taken, inside the caller's transaction, under the
|
|
162
|
+
* same locks as `takeBackGrant`. While the payment is paid and its period still ahead, the days go
|
|
163
|
+
* back right after that period: dated access and every later stored period move forward by them
|
|
164
|
+
* (the take-back in reverse). Otherwise (refunded in full, or the period used up) they are a grant
|
|
165
|
+
* at the end, from the latest of the trial's end, dated access and now, and that is the period the
|
|
166
|
+
* payment pays for.
|
|
167
|
+
*/
|
|
168
|
+
export async function giveBackDays(ctx: BillingContext, input: GiveBackDaysInput): Promise<GiveBack> {
|
|
169
|
+
const record = await findEntitlementRecord(ctx, input.userId);
|
|
170
|
+
// The caller's payment row references the account, which its key share lock keeps.
|
|
171
|
+
if (record === null) throw new Error("@softure-ai/billing: days to give back have no account");
|
|
172
|
+
const { timezone } = ctx.config;
|
|
173
|
+
const { grant, days, now } = input;
|
|
174
|
+
const { paidUntil } = record;
|
|
175
|
+
let period: GiveBack["period"];
|
|
176
|
+
let until: Date;
|
|
177
|
+
if (input.isPaid && grant.until > now && paidUntil !== null && paidUntil >= grant.until) {
|
|
178
|
+
await shiftLaterPeriods(ctx.db, { userId: input.userId, after: grant.until, days, direction: "forward", timezone });
|
|
179
|
+
period = { kind: "period", from: grant.from, until: moveForwardByDays(grant.until, days, timezone) };
|
|
180
|
+
until = moveForwardByDays(paidUntil, days, timezone);
|
|
181
|
+
} else {
|
|
182
|
+
const from = getGrantStart(record, now);
|
|
183
|
+
until = moveForwardByDays(from, days, timezone);
|
|
184
|
+
period = { kind: "period", from, until };
|
|
185
|
+
}
|
|
186
|
+
const changed = await changeEntitlement(ctx, input.userId, { type: "grant", until });
|
|
187
|
+
// The account is locked and the end lies at least a day past dated access and now.
|
|
188
|
+
if (!changed.ok) throw new Error(`@softure-ai/billing: giving back refunded days failed with ${changed.error}`);
|
|
189
|
+
return { entitlement: changed.value, period };
|
|
190
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Account ids are auth's uuids, and the module's own rows use uuids too; anything else names no row
|
|
2
|
+
// and is answered without a query (Postgres would refuse to compare it with a uuid column).
|
|
3
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
|
+
|
|
5
|
+
/** Whether `value` can be a row id (a uuid). */
|
|
6
|
+
export function isUuid(value: string): boolean {
|
|
7
|
+
return UUID.test(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isUserId(value: string): boolean {
|
|
11
|
+
return isUuid(value);
|
|
12
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Stripe's unit for an amount (https://docs.stripe.com/currencies, read 2026-10-04): the minor unit
|
|
2
|
+
// of a two-decimal currency unless Stripe lists the currency as zero- or three-decimal. Billing keeps
|
|
3
|
+
// every amount in the minor unit it pins (`src/currency-digits.ts`, ISO 4217); the two differ where
|
|
4
|
+
// ISO has no minor unit and Stripe keeps one (ISK and UGX, Stripe's "special cases") or ISO has three
|
|
5
|
+
// decimals Stripe does not take (IQD, LYD), so the adapter converts at its boundary: amounts sent to
|
|
6
|
+
// Checkout, and amounts the webhook reads back.
|
|
7
|
+
import type { PlanPrice } from "./contract.js";
|
|
8
|
+
import { getMinorUnitDigits } from "./price.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Charged without a minor unit. UGX is not here: Stripe still takes it as a two-decimal value whose
|
|
12
|
+
* decimals are always 00 (as ISK). The guide's text export omits the list; this is Stripe's
|
|
13
|
+
* long-standing list, which the guide's index matches.
|
|
14
|
+
*/
|
|
15
|
+
export const STRIPE_ZERO_DECIMAL_CURRENCIES: ReadonlySet<string> = new Set([
|
|
16
|
+
"BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Charged with three decimals, and the last one must be 0 (Stripe's earlier guide; the current
|
|
21
|
+
* text export omits the section).
|
|
22
|
+
*/
|
|
23
|
+
export const STRIPE_THREE_DECIMAL_CURRENCIES: ReadonlySet<string> = new Set(["BHD", "JOD", "KWD", "OMR", "TND"]);
|
|
24
|
+
|
|
25
|
+
/** Digits of the unit Stripe takes for `currency` (ISO 4217, upper case): 0, 2 or 3. */
|
|
26
|
+
export function getStripeMinorUnitDigits(currency: string): number {
|
|
27
|
+
if (STRIPE_ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
|
|
28
|
+
if (STRIPE_THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How many digits Stripe's unit has beyond billing's (negative when it has fewer). */
|
|
33
|
+
function getDigitShift(currency: string): number {
|
|
34
|
+
return getStripeMinorUnitDigits(currency) - getMinorUnitDigits(currency);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The price as Stripe's `unit_amount`, e.g. ISK 1,500 (`amount: 1500`) as 150000; null when Stripe
|
|
39
|
+
* cannot charge it exactly (a fraction of Stripe's unit, or a three-decimal amount not ending in 0).
|
|
40
|
+
*/
|
|
41
|
+
export function toStripeAmount(price: PlanPrice): number | null {
|
|
42
|
+
const shift = getDigitShift(price.currency);
|
|
43
|
+
const amount = shift >= 0 ? price.amount * 10 ** shift : price.amount / 10 ** -shift;
|
|
44
|
+
if (!Number.isInteger(amount)) return null;
|
|
45
|
+
if (STRIPE_THREE_DECIMAL_CURRENCIES.has(price.currency) && amount % 10 !== 0) return null;
|
|
46
|
+
return amount;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An amount Stripe reports (a checkout's total, a charge's refunded total) in billing's unit,
|
|
51
|
+
* rounded down when it is not whole, so a refunded total is never over-counted.
|
|
52
|
+
*/
|
|
53
|
+
export function fromStripeAmount(amount: number, currency: string): number {
|
|
54
|
+
const shift = getDigitShift(currency);
|
|
55
|
+
return shift >= 0 ? Math.floor(amount / 10 ** shift) : amount * 10 ** -shift;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Why Stripe cannot charge `price` exactly, for the config error; null when it can. */
|
|
59
|
+
export function describeStripePriceProblem(price: PlanPrice): string | null {
|
|
60
|
+
if (toStripeAmount(price) !== null) return null;
|
|
61
|
+
if (STRIPE_THREE_DECIMAL_CURRENCIES.has(price.currency)) {
|
|
62
|
+
return `stripe() charges ${price.currency} in multiples of 10 of its minor unit; round the amount to end in 0`;
|
|
63
|
+
}
|
|
64
|
+
return `stripe() charges ${price.currency} with ${String(getStripeMinorUnitDigits(price.currency))} decimals; the amount must be a whole number of them`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Stripe webhooks, pure: the `Stripe-Signature` check and the events billing acts on, narrowed with
|
|
2
|
+
// zod. Stripe signs `<timestamp>.<raw body>` with HMAC-SHA256 under the endpoint's secret
|
|
3
|
+
// (https://docs.stripe.com/webhooks#verify-manually); a timestamp older or newer than the tolerance
|
|
4
|
+
// is a replay and fails like a wrong signature. Nothing here touches the database.
|
|
5
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { fromStripeAmount } from "./stripe-currency.js";
|
|
9
|
+
|
|
10
|
+
/** The header Stripe signs every delivery with (lower case, as `Headers` returns it). */
|
|
11
|
+
export const STRIPE_SIGNATURE_HEADER = "stripe-signature";
|
|
12
|
+
/** How far a delivery's timestamp may be from now, in seconds: Stripe's default replay window. */
|
|
13
|
+
export const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
14
|
+
/** The metadata keys a checkout session carries (`stripe()` sets them). */
|
|
15
|
+
export const STRIPE_METADATA = { userId: "softure_user_id", planId: "softure_plan_id" } as const;
|
|
16
|
+
|
|
17
|
+
export type StripeWebhookError = "billing.webhook_invalid";
|
|
18
|
+
|
|
19
|
+
/** The longest id billing stores; Stripe's ids are far shorter. */
|
|
20
|
+
const MAX_ID_LENGTH = 255;
|
|
21
|
+
const SIGNATURE_PATTERN = /^[0-9a-f]{64}$/;
|
|
22
|
+
|
|
23
|
+
function sign(secret: string, timestamp: number, payload: string): string {
|
|
24
|
+
return createHmac("sha256", secret).update(`${String(timestamp)}.${payload}`, "utf8").digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SignStripePayloadInput {
|
|
28
|
+
readonly payload: string;
|
|
29
|
+
readonly secret: string;
|
|
30
|
+
/** Unix seconds. */
|
|
31
|
+
readonly timestamp: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A `Stripe-Signature` value as Stripe sends it, e.g. for a test that plays Stripe. */
|
|
35
|
+
export function signStripePayload({ payload, secret, timestamp }: SignStripePayloadInput): string {
|
|
36
|
+
return `t=${String(timestamp)},v1=${sign(secret, timestamp, payload)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VerifyStripeSignatureInput {
|
|
40
|
+
readonly payload: string;
|
|
41
|
+
/** The `Stripe-Signature` header, or null when the request had none. */
|
|
42
|
+
readonly header: string | null;
|
|
43
|
+
readonly secret: string;
|
|
44
|
+
readonly now: Date;
|
|
45
|
+
readonly toleranceSeconds?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether Stripe signed `payload` with `secret` within the tolerance of `now`. Any `v1` entry may
|
|
50
|
+
* match: Stripe sends one per secret while an old secret is still rolling off.
|
|
51
|
+
*/
|
|
52
|
+
export function verifyStripeSignature(input: VerifyStripeSignatureInput): Ok<undefined> | Err<StripeWebhookError> {
|
|
53
|
+
const { payload, header, secret, now } = input;
|
|
54
|
+
const tolerance = input.toleranceSeconds ?? STRIPE_SIGNATURE_TOLERANCE_SECONDS;
|
|
55
|
+
if (header === null || secret === "") return err("billing.webhook_invalid");
|
|
56
|
+
|
|
57
|
+
let timestamp: number | null = null;
|
|
58
|
+
const signatures: string[] = [];
|
|
59
|
+
for (const part of header.split(",")) {
|
|
60
|
+
const separator = part.indexOf("=");
|
|
61
|
+
if (separator <= 0) continue;
|
|
62
|
+
const key = part.slice(0, separator).trim();
|
|
63
|
+
const value = part.slice(separator + 1).trim();
|
|
64
|
+
if (key === "t" && /^\d{1,12}$/.test(value)) timestamp = Number(value);
|
|
65
|
+
if (key === "v1" && SIGNATURE_PATTERN.test(value)) signatures.push(value);
|
|
66
|
+
}
|
|
67
|
+
if (timestamp === null || signatures.length === 0) return err("billing.webhook_invalid");
|
|
68
|
+
if (Math.abs(Math.floor(now.getTime() / 1000) - timestamp) > tolerance) return err("billing.webhook_invalid");
|
|
69
|
+
|
|
70
|
+
const expected = Buffer.from(sign(secret, timestamp, payload), "hex");
|
|
71
|
+
const isSigned = signatures.some((signature) => timingSafeEqual(Buffer.from(signature, "hex"), expected));
|
|
72
|
+
return isSigned ? ok() : err("billing.webhook_invalid");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A checkout Stripe reports as paid, with what `stripe()` put in its metadata. */
|
|
76
|
+
export interface PaidCheckout {
|
|
77
|
+
/** The Checkout Session id (`cs_...`). */
|
|
78
|
+
readonly checkoutId: string;
|
|
79
|
+
/** The PaymentIntent id (`pi_...`) refunds name; null for a checkout without a charge. */
|
|
80
|
+
readonly paymentId: string | null;
|
|
81
|
+
readonly userId: string;
|
|
82
|
+
readonly planId: string;
|
|
83
|
+
/** What Stripe charged, in billing's unit (the pinned minor unit, converted from Stripe's). */
|
|
84
|
+
readonly amount: number;
|
|
85
|
+
/** ISO 4217, upper case. */
|
|
86
|
+
readonly currency: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A refund Stripe reports as failed or canceled: its money is back with the customer's payment. */
|
|
90
|
+
export interface FailedRefund {
|
|
91
|
+
/** The PaymentIntent id (`pi_...`) of the refunded payment. */
|
|
92
|
+
readonly paymentId: string;
|
|
93
|
+
/** The Refund id (`re_...`). */
|
|
94
|
+
readonly refundId: string;
|
|
95
|
+
/** What the refund was for, in billing's unit. */
|
|
96
|
+
readonly amount: number;
|
|
97
|
+
/** When Stripe created the refund. */
|
|
98
|
+
readonly refundCreatedAt: Date;
|
|
99
|
+
/** When Stripe reported the failure (the event's `created`). */
|
|
100
|
+
readonly failedAt: Date;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What one delivery asks of billing. */
|
|
104
|
+
export type StripeWebhookEvent =
|
|
105
|
+
/** A paid checkout: grant its plan, once. */
|
|
106
|
+
| { readonly type: "checkout_paid"; readonly eventId: string; readonly checkout: PaidCheckout }
|
|
107
|
+
/** A charge refunded in full: take back the access its payment gave, once. `snapshotAt`: when Stripe took the charge's state (the event's `created`). */
|
|
108
|
+
| { readonly type: "payment_refunded"; readonly eventId: string; readonly paymentId: string; readonly snapshotAt: Date }
|
|
109
|
+
/** A charge refunded in part: `amountRefunded` is the total refunded so far, in billing's unit, as of `snapshotAt`. */
|
|
110
|
+
| {
|
|
111
|
+
readonly type: "payment_partially_refunded";
|
|
112
|
+
readonly eventId: string;
|
|
113
|
+
readonly paymentId: string;
|
|
114
|
+
readonly amountRefunded: number;
|
|
115
|
+
readonly snapshotAt: Date;
|
|
116
|
+
}
|
|
117
|
+
/** A refund that failed (or was canceled): give back what it took, once per refund. */
|
|
118
|
+
| { readonly type: "refund_failed"; readonly eventId: string; readonly failure: FailedRefund }
|
|
119
|
+
/** Nothing to do: another event type, a checkout still waiting for its money, a charge with nothing refunded, a session billing did not create. */
|
|
120
|
+
| { readonly type: "ignored"; readonly eventId: string; readonly reason: string };
|
|
121
|
+
|
|
122
|
+
const idSchema = z.string().min(1).max(MAX_ID_LENGTH);
|
|
123
|
+
|
|
124
|
+
/** Unix seconds, as Stripe dates every event and object (up to the end of year 9999). */
|
|
125
|
+
const unixSecondsSchema = z.number().int().min(0).max(253_402_300_799);
|
|
126
|
+
|
|
127
|
+
const envelopeSchema = z.object({
|
|
128
|
+
id: idSchema,
|
|
129
|
+
type: z.string().min(1).max(MAX_ID_LENGTH),
|
|
130
|
+
/** When the event (and the snapshot of its object) was made; billing needs it for refunds only. */
|
|
131
|
+
created: unixSecondsSchema.optional(),
|
|
132
|
+
data: z.object({ object: z.unknown() }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** An expanded object or its id. */
|
|
136
|
+
const referenceSchema = z.union([idSchema, z.object({ id: idSchema })]).transform((value) => (typeof value === "string" ? value : value.id));
|
|
137
|
+
|
|
138
|
+
const sessionSchema = z.object({
|
|
139
|
+
id: idSchema,
|
|
140
|
+
mode: z.string(),
|
|
141
|
+
payment_status: z.string(),
|
|
142
|
+
payment_intent: referenceSchema.nullish(),
|
|
143
|
+
amount_total: z.number().int().min(0).nullish(),
|
|
144
|
+
currency: z.string().regex(/^[a-zA-Z]{3}$/).nullish(),
|
|
145
|
+
metadata: z.record(z.string(), z.string()).nullish(),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const chargeSchema = z.object({
|
|
149
|
+
payment_intent: referenceSchema.nullish(),
|
|
150
|
+
refunded: z.boolean(),
|
|
151
|
+
/** Stripe sends it on every charge; needed only for a partial refund, to convert the amount. */
|
|
152
|
+
currency: z.string().regex(/^[a-zA-Z]{3}$/).optional(),
|
|
153
|
+
/** The total refunded so far (Stripe sends it on every charge); needed only for a partial refund. */
|
|
154
|
+
amount_refunded: z.number().int().min(0).optional(),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const refundSchema = z.object({
|
|
158
|
+
id: idSchema,
|
|
159
|
+
payment_intent: referenceSchema.nullish(),
|
|
160
|
+
amount: z.number().int().min(0),
|
|
161
|
+
currency: z.string().regex(/^[a-zA-Z]{3}$/),
|
|
162
|
+
created: unixSecondsSchema,
|
|
163
|
+
status: z.string().nullish(),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/** Refund statuses whose money went back to the payment: the refund took nothing in the end. */
|
|
167
|
+
const FAILED_REFUND_STATUSES: ReadonlySet<string> = new Set(["failed", "canceled"]);
|
|
168
|
+
|
|
169
|
+
/** Payment statuses of a checkout that has its money (`no_payment_required`: a 100% discount). */
|
|
170
|
+
const SETTLED_PAYMENT_STATUSES: ReadonlySet<string> = new Set(["paid", "no_payment_required"]);
|
|
171
|
+
|
|
172
|
+
function readCheckout(eventId: string, object: unknown): StripeWebhookEvent | null {
|
|
173
|
+
const parsed = sessionSchema.safeParse(object);
|
|
174
|
+
if (!parsed.success) return null;
|
|
175
|
+
const session = parsed.data;
|
|
176
|
+
const userId = session.metadata?.[STRIPE_METADATA.userId];
|
|
177
|
+
const planId = session.metadata?.[STRIPE_METADATA.planId];
|
|
178
|
+
if (session.mode !== "payment" || userId === undefined || planId === undefined) {
|
|
179
|
+
return { type: "ignored", eventId, reason: "a checkout billing did not create" };
|
|
180
|
+
}
|
|
181
|
+
if (!SETTLED_PAYMENT_STATUSES.has(session.payment_status)) {
|
|
182
|
+
// A delayed method (a bank transfer): `checkout.session.async_payment_succeeded` follows.
|
|
183
|
+
return { type: "ignored", eventId, reason: `a checkout whose payment is ${session.payment_status}` };
|
|
184
|
+
}
|
|
185
|
+
if (session.amount_total === null || session.amount_total === undefined || session.currency === null || session.currency === undefined) return null;
|
|
186
|
+
const currency = session.currency.toUpperCase();
|
|
187
|
+
return {
|
|
188
|
+
type: "checkout_paid",
|
|
189
|
+
eventId,
|
|
190
|
+
checkout: {
|
|
191
|
+
checkoutId: session.id,
|
|
192
|
+
paymentId: session.payment_intent ?? null,
|
|
193
|
+
userId,
|
|
194
|
+
planId,
|
|
195
|
+
amount: fromStripeAmount(session.amount_total, currency),
|
|
196
|
+
currency,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function fromUnixSeconds(seconds: number): Date {
|
|
202
|
+
return new Date(seconds * 1000);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readRefund(eventId: string, object: unknown, created: number | undefined): StripeWebhookEvent | null {
|
|
206
|
+
const parsed = chargeSchema.safeParse(object);
|
|
207
|
+
if (!parsed.success || created === undefined) return null;
|
|
208
|
+
const paymentId = parsed.data.payment_intent;
|
|
209
|
+
if (paymentId === null || paymentId === undefined) return { type: "ignored", eventId, reason: "a refunded charge without a payment" };
|
|
210
|
+
const snapshotAt = fromUnixSeconds(created);
|
|
211
|
+
if (parsed.data.refunded) return { type: "payment_refunded", eventId, paymentId, snapshotAt };
|
|
212
|
+
const { amount_refunded: amountRefunded, currency } = parsed.data;
|
|
213
|
+
if (amountRefunded === undefined || currency === undefined) return null;
|
|
214
|
+
if (amountRefunded === 0) return { type: "ignored", eventId, reason: "a charge with nothing refunded" };
|
|
215
|
+
return { type: "payment_partially_refunded", eventId, paymentId, amountRefunded: fromStripeAmount(amountRefunded, currency.toUpperCase()), snapshotAt };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A Refund event: `refund.failed` always reports a failure, the update events only with a failed or canceled status. */
|
|
219
|
+
function readFailedRefund(eventId: string, object: unknown, created: number | undefined, isFailure: boolean): StripeWebhookEvent | null {
|
|
220
|
+
const parsed = refundSchema.safeParse(object);
|
|
221
|
+
if (!parsed.success || created === undefined) return null;
|
|
222
|
+
const refund = parsed.data;
|
|
223
|
+
if (!isFailure && !FAILED_REFUND_STATUSES.has(refund.status ?? "")) {
|
|
224
|
+
return { type: "ignored", eventId, reason: `a refund whose status is ${refund.status ?? "unknown"}` };
|
|
225
|
+
}
|
|
226
|
+
const paymentId = refund.payment_intent;
|
|
227
|
+
if (paymentId === null || paymentId === undefined) return { type: "ignored", eventId, reason: "a failed refund without a payment" };
|
|
228
|
+
const failure: FailedRefund = {
|
|
229
|
+
paymentId,
|
|
230
|
+
refundId: refund.id,
|
|
231
|
+
amount: fromStripeAmount(refund.amount, refund.currency.toUpperCase()),
|
|
232
|
+
refundCreatedAt: fromUnixSeconds(refund.created),
|
|
233
|
+
failedAt: fromUnixSeconds(created),
|
|
234
|
+
};
|
|
235
|
+
return { type: "refund_failed", eventId, failure };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The event a verified payload carries. A body that is not a Stripe event, or an event billing acts
|
|
240
|
+
* on whose object lacks what billing needs, is `billing.webhook_invalid`.
|
|
241
|
+
*/
|
|
242
|
+
export function parseStripeEvent(payload: string): Ok<StripeWebhookEvent> | Err<StripeWebhookError> {
|
|
243
|
+
let json: unknown;
|
|
244
|
+
try {
|
|
245
|
+
json = JSON.parse(payload);
|
|
246
|
+
} catch {
|
|
247
|
+
return err("billing.webhook_invalid");
|
|
248
|
+
}
|
|
249
|
+
const envelope = envelopeSchema.safeParse(json);
|
|
250
|
+
if (!envelope.success) return err("billing.webhook_invalid");
|
|
251
|
+
const { id, type, data, created } = envelope.data;
|
|
252
|
+
let event: StripeWebhookEvent | null;
|
|
253
|
+
switch (type) {
|
|
254
|
+
case "checkout.session.completed":
|
|
255
|
+
case "checkout.session.async_payment_succeeded":
|
|
256
|
+
event = readCheckout(id, data.object);
|
|
257
|
+
break;
|
|
258
|
+
case "charge.refunded":
|
|
259
|
+
event = readRefund(id, data.object, created);
|
|
260
|
+
break;
|
|
261
|
+
case "refund.failed":
|
|
262
|
+
event = readFailedRefund(id, data.object, created, true);
|
|
263
|
+
break;
|
|
264
|
+
case "refund.updated":
|
|
265
|
+
case "charge.refund.updated":
|
|
266
|
+
event = readFailedRefund(id, data.object, created, false);
|
|
267
|
+
break;
|
|
268
|
+
default:
|
|
269
|
+
event = { type: "ignored", eventId: id, reason: `the event type ${type}` };
|
|
270
|
+
}
|
|
271
|
+
return event === null ? err("billing.webhook_invalid") : ok(event);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `verifyStripeSignature`, then `parseStripeEvent`: nothing unsigned is parsed. */
|
|
275
|
+
export function readStripeWebhook(input: VerifyStripeSignatureInput): Ok<StripeWebhookEvent> | Err<StripeWebhookError> {
|
|
276
|
+
const verified = verifyStripeSignature(input);
|
|
277
|
+
return verified.ok ? parseStripeEvent(input.payload) : verified;
|
|
278
|
+
}
|