@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,434 @@
|
|
|
1
|
+
// Payments a provider reports through its webhook: a paid checkout grants its plan and a refund
|
|
2
|
+
// takes back what that payment granted (a partial one by the `partialRefunds` policy), each exactly
|
|
3
|
+
// once. `billing.payments` holds one row per paid checkout with the grant it caused (a period or
|
|
4
|
+
// lifetime), written in the transaction of the grant, so a delivery Stripe repeats (or two events
|
|
5
|
+
// for one checkout) finds the row and changes nothing.
|
|
6
|
+
// A payment keeps the total refunded so far (`refunded_amount`, the provider's cumulative figure),
|
|
7
|
+
// so a repeated or stale refund delivery finds nothing new and changes nothing. A refund that fails
|
|
8
|
+
// later gives back what it took (`failRefund`), once per refund id (`billing.refund_failures`); a
|
|
9
|
+
// charge snapshot taken before a failure is corrected by the failed refunds it still counts. A newer
|
|
10
|
+
// snapshot that reports no more than billing counts (a new refund after a failure billing has not
|
|
11
|
+
// heard of yet) is kept on the payment, and each failure applies it once it reports more.
|
|
12
|
+
// Locks: the account first (key share), like `changeEntitlement` and the privacy erase; a refund
|
|
13
|
+
// then takes the entitlement before its payment row (`lockEntitlementRow`), as a manual revoke does,
|
|
14
|
+
// so a refund and a revoke of one account queue on the entitlement instead of deadlocking on the
|
|
15
|
+
// rows each moves back.
|
|
16
|
+
import { users } from "@softure-ai/auth";
|
|
17
|
+
import { err, ok, type Err, type Ok } from "@softure-ai/core";
|
|
18
|
+
import type { Queryable } from "@softure-ai/db";
|
|
19
|
+
import { and, eq, gt, lte, sql } from "drizzle-orm";
|
|
20
|
+
import type { Entitlement, PaymentGrant } from "../contract.js";
|
|
21
|
+
import { findPlan } from "../plans.js";
|
|
22
|
+
import { resolveEntitlement } from "../entitlement.js";
|
|
23
|
+
import { getRestoredDays, moveBackByDays, type RefundShare } from "../refund.js";
|
|
24
|
+
import { payments, refundFailures } from "../schema.js";
|
|
25
|
+
import { readStripeWebhook, type FailedRefund, type PaidCheckout, type StripeWebhookError } from "../stripe-webhook.js";
|
|
26
|
+
import { changeEntitlement, findEntitlementRecord, type BillingContext } from "./entitlements.js";
|
|
27
|
+
import { getBillingOptions, getEntitlementPolicy } from "./options.js";
|
|
28
|
+
import { applyPlan, getBillingPlans } from "./plans.js";
|
|
29
|
+
import { giveBackDays, hasActiveManualLifetime, hasPaidLifetimePayment, lockEntitlementRow, takeBackGrant } from "./take-back.js";
|
|
30
|
+
import { isUserId } from "./user-id.js";
|
|
31
|
+
|
|
32
|
+
/** The name of `stripe()`, under which its webhook stores payments. */
|
|
33
|
+
export const STRIPE_PROVIDER = "stripe";
|
|
34
|
+
|
|
35
|
+
export interface RecordPaymentInput extends PaidCheckout {
|
|
36
|
+
/** The adapter's name, e.g. `stripe`. */
|
|
37
|
+
readonly provider: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a webhook delivery changed. */
|
|
41
|
+
export type PaymentOutcome =
|
|
42
|
+
/** A new paid checkout: its plan was granted. */
|
|
43
|
+
| { readonly status: "granted"; readonly entitlement: Entitlement }
|
|
44
|
+
/** A full refund of a recorded payment (or the partial one that completes it): what it granted was taken back. */
|
|
45
|
+
| { readonly status: "refunded"; readonly entitlement: Entitlement }
|
|
46
|
+
/** A partial refund of a recorded payment: access changed by the `partialRefunds` policy. */
|
|
47
|
+
| { readonly status: "partially_refunded"; readonly entitlement: Entitlement }
|
|
48
|
+
/** A refund of a recorded payment failed: what it took was given back (nothing when billing never counted it). */
|
|
49
|
+
| { readonly status: "refund_failed"; readonly entitlement: Entitlement }
|
|
50
|
+
/** Recorded (or refunded) already: a repeated delivery, nothing changed. */
|
|
51
|
+
| { readonly status: "duplicate" }
|
|
52
|
+
/** A refund of a payment billing never recorded: nothing to take back. */
|
|
53
|
+
| { readonly status: "unknown_payment" }
|
|
54
|
+
/** An event billing does not act on. */
|
|
55
|
+
| { readonly status: "ignored"; readonly reason: string };
|
|
56
|
+
|
|
57
|
+
export type RecordPaymentError = "billing.account_unknown" | "billing.plan_unknown";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Records a paid checkout and grants one payment of its plan, in one transaction: `duplicate` when
|
|
61
|
+
* the checkout was recorded before. An account or plan that no longer exists stores nothing (the
|
|
62
|
+
* money is refunded in the provider's dashboard). Database errors propagate.
|
|
63
|
+
*/
|
|
64
|
+
export async function recordPayment(ctx: BillingContext, input: RecordPaymentInput): Promise<Ok<PaymentOutcome> | Err<RecordPaymentError>> {
|
|
65
|
+
if (findPlan(getBillingPlans(ctx.config), input.planId) === undefined) return err("billing.plan_unknown");
|
|
66
|
+
if (!isUserId(input.userId)) return err("billing.account_unknown");
|
|
67
|
+
return ctx.db.transaction(async (tx) => {
|
|
68
|
+
const now = ctx.clock.now();
|
|
69
|
+
// A shared lock: the account cannot be deleted before the grant below.
|
|
70
|
+
const [account] = await tx.select({ id: users.id }).from(users).where(eq(users.id, input.userId)).for("key share");
|
|
71
|
+
if (account === undefined) return err("billing.account_unknown");
|
|
72
|
+
|
|
73
|
+
// A concurrent delivery of the same checkout waits here for this transaction, then conflicts.
|
|
74
|
+
const inserted = await tx
|
|
75
|
+
.insert(payments)
|
|
76
|
+
.values({
|
|
77
|
+
userId: input.userId,
|
|
78
|
+
provider: input.provider,
|
|
79
|
+
checkoutId: input.checkoutId,
|
|
80
|
+
paymentId: input.paymentId,
|
|
81
|
+
planId: input.planId,
|
|
82
|
+
amount: input.amount,
|
|
83
|
+
currency: input.currency,
|
|
84
|
+
status: "paid",
|
|
85
|
+
paidAt: now,
|
|
86
|
+
})
|
|
87
|
+
.onConflictDoNothing()
|
|
88
|
+
.returning();
|
|
89
|
+
if (inserted.length === 0) return ok({ status: "duplicate" });
|
|
90
|
+
|
|
91
|
+
const applied = await applyPlan({ ...ctx, db: tx }, input.userId, input.planId);
|
|
92
|
+
// The plan and the locked account were checked above, and a plan grant always ends later.
|
|
93
|
+
if (!applied.ok) throw new Error(`@softure-ai/billing: granting the paid plan "${input.planId}" failed with ${applied.error}`);
|
|
94
|
+
const [payment] = inserted;
|
|
95
|
+
const { grant } = applied.value;
|
|
96
|
+
// A plan grant always adds a period of at least a day, or lifetime access.
|
|
97
|
+
if (payment === undefined || grant === null) throw new Error(`@softure-ai/billing: the paid plan "${input.planId}" was granted but its payment row or grant is missing`);
|
|
98
|
+
await tx.update(payments).set(getGrantColumns(grant)).where(eq(payments.id, payment.id));
|
|
99
|
+
return ok({ status: "granted", entitlement: applied.value.entitlement });
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface RefundPaymentInput {
|
|
104
|
+
readonly provider: string;
|
|
105
|
+
/** The provider's payment id the refund names (a Stripe PaymentIntent). */
|
|
106
|
+
readonly paymentId: string;
|
|
107
|
+
/**
|
|
108
|
+
* For a partial refund, the total refunded so far in the currency's minor unit (Stripe's
|
|
109
|
+
* `amount_refunded`); omitted when the payment is refunded in full. A total that reaches the
|
|
110
|
+
* payment's amount is a full refund.
|
|
111
|
+
*/
|
|
112
|
+
readonly amountRefunded?: number;
|
|
113
|
+
/**
|
|
114
|
+
* When the provider took the charge's state the delivery reports (Stripe's event `created`); now
|
|
115
|
+
* when omitted. Refunds that failed after it and were created before it are not counted in it.
|
|
116
|
+
*/
|
|
117
|
+
readonly observedAt?: Date;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GrantColumns {
|
|
121
|
+
readonly grantKind: "period" | "lifetime" | null;
|
|
122
|
+
readonly grantedFrom: Date | null;
|
|
123
|
+
readonly grantedUntil: Date | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The payment row's columns for what its grant added. */
|
|
127
|
+
export function getGrantColumns(grant: PaymentGrant): GrantColumns {
|
|
128
|
+
if (grant.kind === "lifetime") return { grantKind: "lifetime", grantedFrom: null, grantedUntil: null };
|
|
129
|
+
return { grantKind: "period", grantedFrom: grant.from, grantedUntil: grant.until };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The grant a payment row records, or null for a row stored before grants were. */
|
|
133
|
+
export function readGrant(row: GrantColumns): PaymentGrant | null {
|
|
134
|
+
if (row.grantKind === "lifetime") return { kind: "lifetime" };
|
|
135
|
+
if (row.grantKind === "period" && row.grantedFrom !== null && row.grantedUntil !== null) return { kind: "period", from: row.grantedFrom, until: row.grantedUntil };
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Records a refund of a payment and takes back what it granted, in one transaction. A full refund
|
|
141
|
+
* marks it refunded: a period loses its unused days, a lifetime ends unless another paid lifetime
|
|
142
|
+
* payment or an active manual lifetime grant still gives it, and a payment stored before grants were
|
|
143
|
+
* recorded revokes paid access. A partial refund keeps it paid and follows
|
|
144
|
+
* `billing({ partialRefunds })`: `pro_rata` takes back the share of the unused days that the newly
|
|
145
|
+
* refunded money is of the money not refunded before (rounded down) and shortens the payment's
|
|
146
|
+
* stored period by them; `keep_access` takes nothing back. Partial refunds never end a lifetime
|
|
147
|
+
* nor revoke a payment without a recorded grant; the refund that completes the amount does what a
|
|
148
|
+
* full refund does. The reported total first loses the failed refunds it still counts (created by
|
|
149
|
+
* `observedAt`, failed after it). `duplicate` when the refund adds nothing to what was recorded (a
|
|
150
|
+
* repeated or stale delivery); a state newer than every one recorded is then kept for the failure
|
|
151
|
+
* that explains it (`failRefund`). `unknown_payment` when billing never recorded the payment. The
|
|
152
|
+
* days a refund takes are added to the payment's `taken_back_days`, for a failure to give back.
|
|
153
|
+
* Database errors propagate.
|
|
154
|
+
*/
|
|
155
|
+
export async function refundPayment(ctx: BillingContext, input: RefundPaymentInput): Promise<Ok<PaymentOutcome>> {
|
|
156
|
+
return ctx.db.transaction(async (tx) => {
|
|
157
|
+
const now = ctx.clock.now();
|
|
158
|
+
const match = and(eq(payments.provider, input.provider), eq(payments.paymentId, input.paymentId));
|
|
159
|
+
const [found] = await tx.select({ userId: payments.userId }).from(payments).where(match);
|
|
160
|
+
if (found === undefined) return ok({ status: "unknown_payment" });
|
|
161
|
+
const { userId } = found;
|
|
162
|
+
// The account first (the lock order of every change), the entitlement, then the payment row.
|
|
163
|
+
await tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).for("key share");
|
|
164
|
+
await lockEntitlementRow(tx, userId);
|
|
165
|
+
const [payment] = await tx.select().from(payments).where(match).for("update");
|
|
166
|
+
// Erased with the account in the meantime.
|
|
167
|
+
if (payment === undefined) return ok({ status: "duplicate" });
|
|
168
|
+
|
|
169
|
+
const state = { reported: input.amountRefunded ?? payment.amount, observedAt: input.observedAt ?? now };
|
|
170
|
+
const applied = await applyChargeState({ ...ctx, db: tx }, { payment, state, now });
|
|
171
|
+
if (applied !== null) return ok(applied);
|
|
172
|
+
await keepNewerChargeState(tx, payment, state);
|
|
173
|
+
return ok({ status: "duplicate" });
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type PaymentRow = typeof payments.$inferSelect;
|
|
178
|
+
|
|
179
|
+
/** What applying a charge state changed. */
|
|
180
|
+
type AppliedRefund = Extract<PaymentOutcome, { readonly status: "refunded" | "partially_refunded" }>;
|
|
181
|
+
|
|
182
|
+
/** A charge's refunded total as the provider reported it, before any correction, and when it was taken. */
|
|
183
|
+
interface ChargeState {
|
|
184
|
+
readonly reported: number;
|
|
185
|
+
readonly observedAt: Date;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface ApplyChargeStateInput {
|
|
189
|
+
/** The payment's row, read under the caller's locks. */
|
|
190
|
+
readonly payment: PaymentRow;
|
|
191
|
+
readonly state: ChargeState;
|
|
192
|
+
readonly now: Date;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Applies a charge state to a payment the caller locked (the account, the entitlement, the row):
|
|
197
|
+
* the refunded total it reports, less the failed refunds it still counts, takes back what it adds
|
|
198
|
+
* to the recorded total (see `refundPayment`). Null when it adds nothing (the payment is refunded
|
|
199
|
+
* in full, or the total is not above the recorded one). A kept state at or before it is cleared.
|
|
200
|
+
*/
|
|
201
|
+
async function applyChargeState(ctx: BillingContext, { payment, state, now }: ApplyChargeStateInput): Promise<AppliedRefund | null> {
|
|
202
|
+
const total = Math.min(Math.max(state.reported - (await getFailedAmountCounted(ctx.db, payment.id, state.observedAt)), 0), payment.amount);
|
|
203
|
+
const isFull = total >= payment.amount;
|
|
204
|
+
if (payment.status !== "paid" || (!isFull && total <= payment.refundedAmount)) return null;
|
|
205
|
+
|
|
206
|
+
const { userId } = payment;
|
|
207
|
+
const refundsSeenAt = payment.refundsSeenAt !== null && payment.refundsSeenAt > state.observedAt ? payment.refundsSeenAt : state.observedAt;
|
|
208
|
+
const isPendingIncluded = payment.pendingRefundsSeenAt !== null && payment.pendingRefundsSeenAt <= state.observedAt;
|
|
209
|
+
await ctx.db
|
|
210
|
+
.update(payments)
|
|
211
|
+
.set({
|
|
212
|
+
...(isFull ? { status: "refunded", refundedAt: now, refundedAmount: payment.amount } : { refundedAmount: total }),
|
|
213
|
+
refundsSeenAt,
|
|
214
|
+
...(isPendingIncluded ? { pendingRefundedAmount: null, pendingRefundsSeenAt: null } : {}),
|
|
215
|
+
})
|
|
216
|
+
.where(eq(payments.id, payment.id));
|
|
217
|
+
|
|
218
|
+
const share = isFull ? undefined : getPartialShare(ctx, { refunded: total - payment.refundedAmount, outstanding: payment.amount - payment.refundedAmount });
|
|
219
|
+
const grant = readGrant(payment);
|
|
220
|
+
const { entitlement, days } = await takeBackGrant(ctx, {
|
|
221
|
+
userId,
|
|
222
|
+
grant,
|
|
223
|
+
now,
|
|
224
|
+
share,
|
|
225
|
+
hasOtherLifetime: async () => (await hasActiveManualLifetime(ctx.db, userId)) || (await hasPaidLifetimePayment(ctx.db, userId, payment.id)),
|
|
226
|
+
});
|
|
227
|
+
if (days > 0 && grant?.kind === "period") {
|
|
228
|
+
// A partially refunded payment stays paid: its stored period ends where the access it still pays for does.
|
|
229
|
+
const grantedUntil = isFull ? grant.until : moveBackByDays(grant.until, days, ctx.config.timezone);
|
|
230
|
+
await ctx.db
|
|
231
|
+
.update(payments)
|
|
232
|
+
.set({ grantedUntil, takenBackDays: payment.takenBackDays + days })
|
|
233
|
+
.where(eq(payments.id, payment.id));
|
|
234
|
+
}
|
|
235
|
+
return { status: isFull ? "refunded" : "partially_refunded", entitlement };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Keeps a charge state billing did not apply when it is newer than every state recorded (applied or
|
|
240
|
+
* kept): it may carry a new refund that a failure billing has not heard of yet hides.
|
|
241
|
+
*/
|
|
242
|
+
async function keepNewerChargeState(tx: Queryable, payment: PaymentRow, state: ChargeState): Promise<void> {
|
|
243
|
+
const isNewer = (seenAt: Date | null) => seenAt === null || state.observedAt > seenAt;
|
|
244
|
+
if (!isNewer(payment.refundsSeenAt) || !isNewer(payment.pendingRefundsSeenAt)) return;
|
|
245
|
+
await tx
|
|
246
|
+
.update(payments)
|
|
247
|
+
.set({ pendingRefundedAmount: state.reported, pendingRefundsSeenAt: state.observedAt })
|
|
248
|
+
.where(eq(payments.id, payment.id));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** The failed refunds of a payment a charge snapshot taken at `observedAt` still counts: created by then, failed after. */
|
|
252
|
+
async function getFailedAmountCounted(tx: Queryable, paymentId: string, observedAt: Date): Promise<number> {
|
|
253
|
+
const [row] = await tx
|
|
254
|
+
.select({ amount: sql<string>`coalesce(sum(${refundFailures.amount}), 0)` })
|
|
255
|
+
.from(refundFailures)
|
|
256
|
+
.where(and(eq(refundFailures.paymentId, paymentId), lte(refundFailures.refundCreatedAt, observedAt), gt(refundFailures.failedAt, observedAt)));
|
|
257
|
+
return Number(row?.amount ?? 0);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface FailRefundInput extends FailedRefund {
|
|
261
|
+
/** The adapter's name, e.g. `stripe`. */
|
|
262
|
+
readonly provider: string;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Records a refund of a payment that failed and gives back what it took, in one transaction, under
|
|
267
|
+
* the locks of `refundPayment`. Billing counted the refund when the newest charge snapshot it
|
|
268
|
+
* recorded was taken after the refund was created and before it failed; then the payment's
|
|
269
|
+
* refunded total drops by the refund's amount, a payment refunded in full is paid again (a lifetime
|
|
270
|
+
* comes back), and its period gets back the failed money's share of the days refunds took
|
|
271
|
+
* (`getRestoredDays`, by `partialRefunds`), with `giveBackDays`. A failure billing never counted is
|
|
272
|
+
* only recorded, so a later snapshot that still counts the refund is corrected by it. A payment
|
|
273
|
+
* stored before grants were recorded gets its total and status back but no access. Then the charge
|
|
274
|
+
* state `refundPayment` kept is applied when, corrected, it reports more than billing now counts: a
|
|
275
|
+
* new refund reported before this failure is taken back once. `duplicate` for a refund already
|
|
276
|
+
* recorded as failed, `unknown_payment` when billing never recorded the payment. Database errors
|
|
277
|
+
* propagate.
|
|
278
|
+
*/
|
|
279
|
+
export async function failRefund(ctx: BillingContext, input: FailRefundInput): Promise<Ok<PaymentOutcome>> {
|
|
280
|
+
return ctx.db.transaction(async (tx) => {
|
|
281
|
+
const now = ctx.clock.now();
|
|
282
|
+
const txCtx = { ...ctx, db: tx };
|
|
283
|
+
const match = and(eq(payments.provider, input.provider), eq(payments.paymentId, input.paymentId));
|
|
284
|
+
const [found] = await tx.select({ userId: payments.userId }).from(payments).where(match);
|
|
285
|
+
if (found === undefined) return ok({ status: "unknown_payment" });
|
|
286
|
+
const { userId } = found;
|
|
287
|
+
// The lock order of `refundPayment`: the account, the entitlement, then the payment row.
|
|
288
|
+
await tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).for("key share");
|
|
289
|
+
await lockEntitlementRow(tx, userId);
|
|
290
|
+
const [payment] = await tx.select().from(payments).where(match).for("update");
|
|
291
|
+
// Erased with the account in the meantime.
|
|
292
|
+
if (payment === undefined) return ok({ status: "unknown_payment" });
|
|
293
|
+
|
|
294
|
+
const recorded = await tx
|
|
295
|
+
.insert(refundFailures)
|
|
296
|
+
.values({
|
|
297
|
+
paymentId: payment.id,
|
|
298
|
+
refundId: input.refundId,
|
|
299
|
+
amount: input.amount,
|
|
300
|
+
refundCreatedAt: input.refundCreatedAt,
|
|
301
|
+
failedAt: input.failedAt,
|
|
302
|
+
recordedAt: now,
|
|
303
|
+
})
|
|
304
|
+
.onConflictDoNothing()
|
|
305
|
+
.returning();
|
|
306
|
+
if (recorded.length === 0) return ok({ status: "duplicate" });
|
|
307
|
+
|
|
308
|
+
const seenAt = payment.refundsSeenAt;
|
|
309
|
+
const isCounted = seenAt !== null && input.refundCreatedAt <= seenAt && seenAt < input.failedAt;
|
|
310
|
+
const restoredAmount = isCounted ? Math.min(input.amount, payment.refundedAmount) : 0;
|
|
311
|
+
const restored = restoredAmount === 0 ? null : await restoreRefund(txCtx, { payment, restoredAmount, now });
|
|
312
|
+
const kept = await applyKeptChargeState(txCtx, payment.id, now);
|
|
313
|
+
return ok({ status: "refund_failed", entitlement: kept?.entitlement ?? restored ?? (await readEntitlement(txCtx, userId, now)) });
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface RestoreRefundInput {
|
|
318
|
+
/** The payment's row, read under the caller's locks. */
|
|
319
|
+
readonly payment: PaymentRow;
|
|
320
|
+
/** What billing counted of the failed refund, above 0. */
|
|
321
|
+
readonly restoredAmount: number;
|
|
322
|
+
readonly now: Date;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Gives back what a failed refund billing counted took (see `failRefund`); the entitlement when access changed. */
|
|
326
|
+
async function restoreRefund(ctx: BillingContext, { payment, restoredAmount, now }: RestoreRefundInput): Promise<Entitlement | null> {
|
|
327
|
+
const { userId } = payment;
|
|
328
|
+
const days = getRestoredDays({
|
|
329
|
+
takenBackDays: payment.takenBackDays,
|
|
330
|
+
refundedAmount: payment.refundedAmount,
|
|
331
|
+
restoredAmount,
|
|
332
|
+
policy: getBillingOptions(ctx.config).partialRefunds,
|
|
333
|
+
});
|
|
334
|
+
const isPaid = payment.status === "paid";
|
|
335
|
+
const grant = readGrant(payment);
|
|
336
|
+
let entitlement: Entitlement | null = null;
|
|
337
|
+
let period: GrantColumns | null = null;
|
|
338
|
+
if (grant?.kind === "period" && days > 0) {
|
|
339
|
+
const given = await giveBackDays(ctx, { userId, grant, isPaid, days, now });
|
|
340
|
+
entitlement = given.entitlement;
|
|
341
|
+
period = getGrantColumns(given.period);
|
|
342
|
+
} else if (grant?.kind === "lifetime" && !isPaid) {
|
|
343
|
+
const changed = await changeEntitlement(ctx, userId, { type: "grant_lifetime" });
|
|
344
|
+
// The account is locked and a lifetime grant is never refused.
|
|
345
|
+
if (!changed.ok) throw new Error(`@softure-ai/billing: giving back a refunded lifetime failed with ${changed.error}`);
|
|
346
|
+
entitlement = changed.value;
|
|
347
|
+
}
|
|
348
|
+
// Below the amount again, so a payment refunded in full is paid again.
|
|
349
|
+
await ctx.db
|
|
350
|
+
.update(payments)
|
|
351
|
+
.set({
|
|
352
|
+
status: "paid",
|
|
353
|
+
refundedAt: null,
|
|
354
|
+
refundedAmount: payment.refundedAmount - restoredAmount,
|
|
355
|
+
takenBackDays: payment.takenBackDays - days,
|
|
356
|
+
...(period === null ? {} : { grantedFrom: period.grantedFrom, grantedUntil: period.grantedUntil }),
|
|
357
|
+
})
|
|
358
|
+
.where(eq(payments.id, payment.id));
|
|
359
|
+
return entitlement;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Applies the charge state a payment kept (see `refundPayment`) when it now reports more than billing counts. */
|
|
363
|
+
async function applyKeptChargeState(ctx: BillingContext, paymentId: string, now: Date): Promise<AppliedRefund | null> {
|
|
364
|
+
// Read again: the restore may have changed the row. The caller holds its lock.
|
|
365
|
+
const [payment] = await ctx.db.select().from(payments).where(eq(payments.id, paymentId));
|
|
366
|
+
if (payment === undefined || payment.pendingRefundedAmount === null || payment.pendingRefundsSeenAt === null) return null;
|
|
367
|
+
return applyChargeState(ctx, { payment, state: { reported: payment.pendingRefundedAmount, observedAt: payment.pendingRefundsSeenAt }, now });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Where the account stands at `now`, read inside the caller's transaction. */
|
|
371
|
+
async function readEntitlement(ctx: BillingContext, userId: string, now: Date): Promise<Entitlement> {
|
|
372
|
+
const record = await findEntitlementRecord(ctx, userId);
|
|
373
|
+
// The caller's payment row references the account, which its key share lock keeps.
|
|
374
|
+
if (record === null) throw new Error("@softure-ai/billing: a refunded payment has no account");
|
|
375
|
+
return resolveEntitlement(record, now, getEntitlementPolicy(ctx.config));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The share a partial refund takes back under the app's policy: none under `keep_access`. */
|
|
379
|
+
function getPartialShare(ctx: BillingContext, share: RefundShare): RefundShare {
|
|
380
|
+
return getBillingOptions(ctx.config).partialRefunds === "keep_access" ? { ...share, refunded: 0 } : share;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface ReceiveStripeWebhookInput {
|
|
384
|
+
/** The raw request body, exactly as Stripe sent it. */
|
|
385
|
+
readonly payload: string;
|
|
386
|
+
/** The `Stripe-Signature` header, or null. */
|
|
387
|
+
readonly signature: string | null;
|
|
388
|
+
/** The endpoint's signing secret (`whsec_...`). */
|
|
389
|
+
readonly secret: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface StripeWebhookReceipt {
|
|
393
|
+
readonly eventId: string;
|
|
394
|
+
readonly outcome:
|
|
395
|
+
| PaymentOutcome
|
|
396
|
+
/** A paid checkout whose account or plan is gone: nothing stored, refund it in Stripe. */
|
|
397
|
+
| { readonly status: "refused"; readonly error: RecordPaymentError; readonly checkoutId: string };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* One Stripe webhook delivery: the signature is checked before anything is parsed or read, then a
|
|
402
|
+
* paid checkout is recorded and granted, a refund takes back what its payment granted (a partial
|
|
403
|
+
* one by the `partialRefunds` policy), and a failed refund gives it back.
|
|
404
|
+
* `billing.webhook_invalid` for a delivery that is not Stripe's (or a replay past the tolerance).
|
|
405
|
+
* Database errors propagate (answer 500, Stripe retries).
|
|
406
|
+
*/
|
|
407
|
+
export async function receiveStripeWebhook(ctx: BillingContext, input: ReceiveStripeWebhookInput): Promise<Ok<StripeWebhookReceipt> | Err<StripeWebhookError>> {
|
|
408
|
+
const event = readStripeWebhook({ payload: input.payload, header: input.signature, secret: input.secret, now: ctx.clock.now() });
|
|
409
|
+
if (!event.ok) return event;
|
|
410
|
+
const { eventId } = event.value;
|
|
411
|
+
switch (event.value.type) {
|
|
412
|
+
case "ignored":
|
|
413
|
+
return ok({ eventId, outcome: { status: "ignored", reason: event.value.reason } });
|
|
414
|
+
case "checkout_paid": {
|
|
415
|
+
const { checkout } = event.value;
|
|
416
|
+
const recorded = await recordPayment(ctx, { provider: STRIPE_PROVIDER, ...checkout });
|
|
417
|
+
return ok({ eventId, outcome: recorded.ok ? recorded.value : { status: "refused", error: recorded.error, checkoutId: checkout.checkoutId } });
|
|
418
|
+
}
|
|
419
|
+
case "payment_refunded": {
|
|
420
|
+
const { paymentId, snapshotAt } = event.value;
|
|
421
|
+
const refunded = await refundPayment(ctx, { provider: STRIPE_PROVIDER, paymentId, observedAt: snapshotAt });
|
|
422
|
+
return ok({ eventId, outcome: refunded.value });
|
|
423
|
+
}
|
|
424
|
+
case "payment_partially_refunded": {
|
|
425
|
+
const { paymentId, amountRefunded, snapshotAt } = event.value;
|
|
426
|
+
const refunded = await refundPayment(ctx, { provider: STRIPE_PROVIDER, paymentId, amountRefunded, observedAt: snapshotAt });
|
|
427
|
+
return ok({ eventId, outcome: refunded.value });
|
|
428
|
+
}
|
|
429
|
+
case "refund_failed": {
|
|
430
|
+
const failed = await failRefund(ctx, { provider: STRIPE_PROVIDER, ...event.value.failure });
|
|
431
|
+
return ok({ eventId, outcome: failed.value });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Plans on the server: the configured list, granting a plan to an account (the one path the
|
|
2
|
+
// manual admin page and a provider's webhook take) and starting a payment through the provider.
|
|
3
|
+
import { users } from "@softure-ai/auth";
|
|
4
|
+
import { err, ok, type Err, type Ok, type SoftureConfig } from "@softure-ai/core";
|
|
5
|
+
import { consumeRateLimit, subjectKey } from "@softure-ai/security/server";
|
|
6
|
+
import { eq } from "drizzle-orm";
|
|
7
|
+
import type { BillingErrorCode, Entitlement, EntitlementEvent, PaymentErrorCode, PaymentGrant, Plan } from "../contract.js";
|
|
8
|
+
import { parseInvoiceDetails, type InvoiceDetailsError, type InvoiceInput } from "../invoice.js";
|
|
9
|
+
import type { InvoiceDetails, PaymentAccount, PaymentProvider, PaymentStart } from "../payment.js";
|
|
10
|
+
import { findPlan, getPlanGrant } from "../plans.js";
|
|
11
|
+
import { getPaymentGrant } from "../refund.js";
|
|
12
|
+
import { changeEntitlement, findEntitlementRecord, type BillingContext } from "./entitlements.js";
|
|
13
|
+
import { getBillingOptions, getBillingRoutes } from "./options.js";
|
|
14
|
+
import { claimHandOver, confirmHandOver, recordPaymentRequest, releaseHandOver } from "./requests.js";
|
|
15
|
+
import { isUserId } from "./user-id.js";
|
|
16
|
+
import { assertPaymentSetup, PAYMENT_BUCKET } from "./setup.js";
|
|
17
|
+
|
|
18
|
+
/** The plans of `billing({ plans })`, in their order. */
|
|
19
|
+
export function getBillingPlans(config: SoftureConfig): readonly Plan[] {
|
|
20
|
+
return getBillingOptions(config).plans;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The provider of `billing({ payment })`. Throws when the app set none: a payment page without one is a bug. */
|
|
24
|
+
export function getPaymentProvider(config: SoftureConfig): PaymentProvider {
|
|
25
|
+
const provider = getBillingOptions(config).payment;
|
|
26
|
+
if (provider === undefined) throw new Error("@softure-ai/billing: billing({ payment }) is not set; the payment page needs a provider such as manual()");
|
|
27
|
+
return provider;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The account with this email (compared trimmed and lower-cased, as auth stores it), or null. */
|
|
31
|
+
export async function findAccountByEmail(ctx: Pick<BillingContext, "db">, email: string): Promise<PaymentAccount | null> {
|
|
32
|
+
const [row] = await ctx.db
|
|
33
|
+
.select({ id: users.id, email: users.email })
|
|
34
|
+
.from(users)
|
|
35
|
+
.where(eq(users.email, email.trim().toLowerCase()))
|
|
36
|
+
.limit(1);
|
|
37
|
+
return row ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The account with this id, or null (also for an id that is not a uuid). */
|
|
41
|
+
export async function findAccountById(ctx: Pick<BillingContext, "db">, id: string): Promise<PaymentAccount | null> {
|
|
42
|
+
if (!isUserId(id)) return null;
|
|
43
|
+
const [row] = await ctx.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, id));
|
|
44
|
+
return row ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Where the account stands after a plan grant, and what the grant added (null when it added nothing). */
|
|
48
|
+
export interface AppliedPlan {
|
|
49
|
+
readonly entitlement: Entitlement;
|
|
50
|
+
readonly grant: PaymentGrant | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `grantPlan` that also says what the grant added, for the payment row that pays for it. The event
|
|
55
|
+
* is computed under the entitlement's lock and may be computed twice (a concurrent first change), so
|
|
56
|
+
* the grant is taken from the last computation, the one applied.
|
|
57
|
+
*/
|
|
58
|
+
export async function applyPlan(ctx: BillingContext, userId: string, planId: string): Promise<Ok<AppliedPlan> | Err<BillingErrorCode | "billing.plan_unknown">> {
|
|
59
|
+
const plan = findPlan(getBillingPlans(ctx.config), planId);
|
|
60
|
+
if (plan === undefined) return err("billing.plan_unknown");
|
|
61
|
+
let grant = null as PaymentGrant | null;
|
|
62
|
+
const changed = await changeEntitlement(ctx, userId, (record, now): EntitlementEvent => {
|
|
63
|
+
const event = getPlanGrant(record, plan, now, ctx.config.timezone);
|
|
64
|
+
grant = getPaymentGrant(record, event, now);
|
|
65
|
+
return event;
|
|
66
|
+
});
|
|
67
|
+
return changed.ok ? ok({ entitlement: changed.value, grant }) : changed;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Grants one payment of the plan: a paid period that starts when the account's current access ends,
|
|
72
|
+
* or lifetime access. Computed under the entitlement's lock, so two grants at once give two periods.
|
|
73
|
+
* It records nothing: a grant made here is not in the account's history and cannot be revoked; an
|
|
74
|
+
* admin's grant goes through `grantPlanManually`.
|
|
75
|
+
*/
|
|
76
|
+
export async function grantPlan(ctx: BillingContext, userId: string, planId: string): Promise<Ok<Entitlement> | Err<BillingErrorCode | "billing.plan_unknown">> {
|
|
77
|
+
const applied = await applyPlan(ctx, userId, planId);
|
|
78
|
+
return applied.ok ? ok(applied.value.entitlement) : applied;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface StartPaymentInput {
|
|
82
|
+
readonly account: PaymentAccount;
|
|
83
|
+
readonly planId: string;
|
|
84
|
+
/** What the form sent; ignored when the provider collects its own details. */
|
|
85
|
+
readonly invoice: InvoiceInput;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type StartPaymentResult = Ok<PaymentStart> | Err<Exclude<PaymentErrorCode, "billing.invoice_details_invalid"> | "security.rate_limited"> | InvoiceDetailsError;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Starts paying for a plan: counts `billing-payment` per account first, checks the plan, that the
|
|
92
|
+
* account has no lifetime access yet and, for a provider that needs them, the invoice details, then
|
|
93
|
+
* starts the provider. A provider that hands requests over (`handsOverRequests`, the manual adapter)
|
|
94
|
+
* gets the request stored first and is called once per open request: asking again refreshes the
|
|
95
|
+
* stored request and answers `requested` without a second hand-over, and a hand-over that failed
|
|
96
|
+
* (an `Err` or a throw) is released, so the next ask tries again; a claim left without an answer (the
|
|
97
|
+
* process stopped) is taken over by an ask a minute later. Database errors and provider
|
|
98
|
+
* throws propagate; a provider whose answer contradicts `handsOverRequests` throws.
|
|
99
|
+
*/
|
|
100
|
+
export async function startPayment(ctx: BillingContext, input: StartPaymentInput): Promise<StartPaymentResult> {
|
|
101
|
+
assertPaymentSetup(ctx.config);
|
|
102
|
+
const provider = getPaymentProvider(ctx.config);
|
|
103
|
+
const limited = await consumeRateLimit(ctx, { bucket: PAYMENT_BUCKET, key: subjectKey(`account:${input.account.id}`) });
|
|
104
|
+
if (!limited.ok) return err("security.rate_limited");
|
|
105
|
+
|
|
106
|
+
const plan = findPlan(getBillingPlans(ctx.config), input.planId);
|
|
107
|
+
if (plan === undefined) return err("billing.plan_unknown");
|
|
108
|
+
const record = await findEntitlementRecord(ctx, input.account.id);
|
|
109
|
+
if (record?.isLifetime === true) return err("billing.lifetime_active");
|
|
110
|
+
let invoice: InvoiceDetails | null = null;
|
|
111
|
+
if (provider.collectsInvoiceDetails) {
|
|
112
|
+
const parsed = parseInvoiceDetails(input.invoice);
|
|
113
|
+
if (!parsed.ok) return parsed;
|
|
114
|
+
invoice = parsed.value;
|
|
115
|
+
}
|
|
116
|
+
const returnUrl = new URL(getBillingRoutes(ctx.config).payment, ctx.config.appOrigin).toString();
|
|
117
|
+
const request = { plan, account: input.account, invoice, returnUrl };
|
|
118
|
+
if (!provider.handsOverRequests) {
|
|
119
|
+
const started = await provider.startPayment(ctx, request);
|
|
120
|
+
if (started.ok && started.value.type === "requested") throw new Error(`@softure-ai/billing: provider "${provider.name}" answered requested but does not set handsOverRequests`);
|
|
121
|
+
return started;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const requestId = await recordPaymentRequest(ctx, { userId: input.account.id, planId: plan.id, invoice, price: plan.price });
|
|
125
|
+
const claimedAt = await claimHandOver(ctx, requestId);
|
|
126
|
+
// Handed over before (asking again refreshed its details), or being handed over by a concurrent ask
|
|
127
|
+
// whose claim is younger than a minute.
|
|
128
|
+
if (claimedAt === null) return ok({ type: "requested" });
|
|
129
|
+
let started: Awaited<ReturnType<PaymentProvider["startPayment"]>>;
|
|
130
|
+
try {
|
|
131
|
+
started = await provider.startPayment(ctx, request);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
await releaseHandOver(ctx, requestId, claimedAt);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
if (!started.ok) {
|
|
137
|
+
await releaseHandOver(ctx, requestId, claimedAt);
|
|
138
|
+
return started;
|
|
139
|
+
}
|
|
140
|
+
if (started.value.type !== "requested") throw new Error(`@softure-ai/billing: provider "${provider.name}" sets handsOverRequests but answered ${started.value.type}`);
|
|
141
|
+
await confirmHandOver(ctx, requestId);
|
|
142
|
+
return started;
|
|
143
|
+
}
|