@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,58 @@
|
|
|
1
|
+
-- Manual payments in the admin page: the invoice requests buyers send (listed until the admin
|
|
2
|
+
-- grants or dismisses them) and the plans the admin grants by hand, each with what it added, so a
|
|
3
|
+
-- mistaken grant is revoked by taking back only that. Provider payments stay in billing.payments.
|
|
4
|
+
-- Invoice details are personal data kept only while a request is open: the closing update clears
|
|
5
|
+
-- them (payment_requests_details_while_open).
|
|
6
|
+
-- Rollback: DROP TABLE billing.manual_grants; DROP TABLE billing.payment_requests; then
|
|
7
|
+
-- DELETE FROM softure.migrations WHERE module = 'billing' AND version = 4;
|
|
8
|
+
CREATE TABLE payment_requests (
|
|
9
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
10
|
+
user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE,
|
|
11
|
+
plan_id text NOT NULL CHECK (length(plan_id) BETWEEN 1 AND 64),
|
|
12
|
+
-- The invoice details as the buyer typed them (trimmed); NULL once the request is closed, or
|
|
13
|
+
-- when the provider collects its own.
|
|
14
|
+
invoice_name text CHECK (length(invoice_name) BETWEEN 1 AND 200),
|
|
15
|
+
invoice_tax_id text CHECK (length(invoice_tax_id) BETWEEN 1 AND 32),
|
|
16
|
+
invoice_address text CHECK (length(invoice_address) BETWEEN 1 AND 500),
|
|
17
|
+
status text NOT NULL CHECK (status IN ('open', 'granted', 'dismissed')),
|
|
18
|
+
requested_at timestamptz NOT NULL,
|
|
19
|
+
closed_at timestamptz,
|
|
20
|
+
CONSTRAINT payment_requests_closed_at_with_status CHECK ((status = 'open') = (closed_at IS NULL)),
|
|
21
|
+
CONSTRAINT payment_requests_closed_after_requested CHECK (closed_at IS NULL OR closed_at >= requested_at),
|
|
22
|
+
CONSTRAINT payment_requests_name_with_address CHECK ((invoice_name IS NULL) = (invoice_address IS NULL)),
|
|
23
|
+
CONSTRAINT payment_requests_tax_id_with_name CHECK (invoice_tax_id IS NULL OR invoice_name IS NOT NULL),
|
|
24
|
+
CONSTRAINT payment_requests_details_while_open CHECK (status = 'open' OR invoice_name IS NULL)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- One open request per account and plan: asking again refreshes it instead of adding a row.
|
|
28
|
+
CREATE UNIQUE INDEX payment_requests_one_open ON payment_requests (user_id, plan_id) WHERE status = 'open';
|
|
29
|
+
CREATE INDEX payment_requests_open_by_age ON payment_requests (requested_at) WHERE status = 'open';
|
|
30
|
+
|
|
31
|
+
CREATE TABLE manual_grants (
|
|
32
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
33
|
+
user_id uuid NOT NULL REFERENCES auth.users (id) ON DELETE CASCADE,
|
|
34
|
+
plan_id text NOT NULL CHECK (length(plan_id) BETWEEN 1 AND 64),
|
|
35
|
+
-- The request the grant answered; NULL for a grant typed in by email.
|
|
36
|
+
request_id uuid UNIQUE REFERENCES payment_requests (id) ON DELETE SET NULL,
|
|
37
|
+
-- The admins who granted and revoked it; NULL once their account is gone.
|
|
38
|
+
granted_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,
|
|
39
|
+
granted_at timestamptz NOT NULL,
|
|
40
|
+
-- What the grant added, as billing.payments records it (0003).
|
|
41
|
+
grant_kind text NOT NULL CHECK (grant_kind IN ('period', 'lifetime')),
|
|
42
|
+
granted_from timestamptz,
|
|
43
|
+
granted_until timestamptz,
|
|
44
|
+
status text NOT NULL CHECK (status IN ('active', 'revoked')),
|
|
45
|
+
revoked_at timestamptz,
|
|
46
|
+
revoked_by uuid REFERENCES auth.users (id) ON DELETE SET NULL,
|
|
47
|
+
CONSTRAINT manual_grants_grant_shape CHECK (
|
|
48
|
+
CASE grant_kind
|
|
49
|
+
WHEN 'period' THEN granted_from IS NOT NULL AND granted_until IS NOT NULL AND granted_until > granted_from
|
|
50
|
+
ELSE granted_from IS NULL AND granted_until IS NULL
|
|
51
|
+
END
|
|
52
|
+
),
|
|
53
|
+
CONSTRAINT manual_grants_revoked_at_with_status CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)),
|
|
54
|
+
CONSTRAINT manual_grants_revoked_by_when_revoked CHECK (revoked_by IS NULL OR status = 'revoked'),
|
|
55
|
+
CONSTRAINT manual_grants_revoked_after_granted CHECK (revoked_at IS NULL OR revoked_at >= granted_at)
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
CREATE INDEX manual_grants_user_id ON manual_grants (user_id);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Partial refunds: how much of each provider payment was refunded so far, in the currency's minor
|
|
2
|
+
-- unit, as the provider reports it (Stripe's cumulative `amount_refunded`). A delivery whose amount
|
|
3
|
+
-- is not above the stored one changes nothing, so repeated and stale deliveries are harmless. A
|
|
4
|
+
-- payment stays `paid` while part of it is refunded and turns `refunded` when all of it is.
|
|
5
|
+
-- Rollback: ALTER TABLE billing.payments DROP CONSTRAINT payments_refunded_amount_by_status;
|
|
6
|
+
-- ALTER TABLE billing.payments DROP COLUMN refunded_amount; then
|
|
7
|
+
-- DELETE FROM softure.migrations WHERE module = 'billing' AND version = 5;
|
|
8
|
+
ALTER TABLE payments ADD COLUMN refunded_amount bigint NOT NULL DEFAULT 0;
|
|
9
|
+
|
|
10
|
+
-- Payments refunded before this migration were refunded in full.
|
|
11
|
+
UPDATE payments SET refunded_amount = amount WHERE status = 'refunded';
|
|
12
|
+
|
|
13
|
+
ALTER TABLE payments ADD CONSTRAINT payments_refunded_amount_by_status CHECK (
|
|
14
|
+
CASE status
|
|
15
|
+
WHEN 'refunded' THEN refunded_amount = amount
|
|
16
|
+
ELSE refunded_amount >= 0 AND (refunded_amount < amount OR amount = 0)
|
|
17
|
+
END
|
|
18
|
+
);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Invoice requests are stored before the provider hands them to the owner, and the owner hears of
|
|
2
|
+
-- an open request once: handed_over_at is set by a conditional update that claims the hand-over
|
|
3
|
+
-- and cleared again when it fails, so the next ask retries. Requests and manual grants record the
|
|
4
|
+
-- plan's price (what was quoted, what was granted). Open requests nobody asks again for expire
|
|
5
|
+
-- (status 'expired', details cleared like any closed request). Invoice details refuse control
|
|
6
|
+
-- characters, so a name cannot add lines to the owner's mail; the CHECKs are NOT VALID, so older
|
|
7
|
+
-- rows are not rewritten, while every insert and update is checked.
|
|
8
|
+
-- Rollback: UPDATE billing.payment_requests SET status = 'dismissed' WHERE status = 'expired';
|
|
9
|
+
-- ALTER TABLE billing.payment_requests DROP CONSTRAINT payment_requests_status_check, then
|
|
10
|
+
-- ADD CONSTRAINT payment_requests_status_check CHECK (status IN ('open', 'granted', 'dismissed'));
|
|
11
|
+
-- ALTER TABLE billing.payment_requests DROP CONSTRAINT payment_requests_invoice_name_printable,
|
|
12
|
+
-- DROP CONSTRAINT payment_requests_invoice_tax_id_printable, DROP CONSTRAINT payment_requests_invoice_address_printable,
|
|
13
|
+
-- DROP CONSTRAINT payment_requests_price_pair, DROP COLUMN handed_over_at, DROP COLUMN amount, DROP COLUMN currency;
|
|
14
|
+
-- ALTER TABLE billing.manual_grants DROP CONSTRAINT manual_grants_price_pair, DROP COLUMN amount, DROP COLUMN currency;
|
|
15
|
+
-- then DELETE FROM softure.migrations WHERE module = 'billing' AND version = 6;
|
|
16
|
+
ALTER TABLE payment_requests ADD COLUMN handed_over_at timestamptz;
|
|
17
|
+
|
|
18
|
+
-- Every open request stored before this migration was stored after its hand-over succeeded.
|
|
19
|
+
UPDATE payment_requests SET handed_over_at = requested_at WHERE status = 'open';
|
|
20
|
+
|
|
21
|
+
-- The plan's price when the request was asked for or last refreshed, in the currency's minor
|
|
22
|
+
-- unit; NULL on rows stored before this migration (the config is not in a migration's reach).
|
|
23
|
+
ALTER TABLE payment_requests ADD COLUMN amount bigint CHECK (amount >= 0);
|
|
24
|
+
ALTER TABLE payment_requests ADD COLUMN currency text CHECK (currency ~ '^[A-Z]{3}$');
|
|
25
|
+
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_price_pair CHECK ((amount IS NULL) = (currency IS NULL));
|
|
26
|
+
|
|
27
|
+
-- What the grant was for: the price its request quoted, else the plan's price when granted.
|
|
28
|
+
ALTER TABLE manual_grants ADD COLUMN amount bigint CHECK (amount >= 0);
|
|
29
|
+
ALTER TABLE manual_grants ADD COLUMN currency text CHECK (currency ~ '^[A-Z]{3}$');
|
|
30
|
+
ALTER TABLE manual_grants ADD CONSTRAINT manual_grants_price_pair CHECK ((amount IS NULL) = (currency IS NULL));
|
|
31
|
+
|
|
32
|
+
ALTER TABLE payment_requests DROP CONSTRAINT payment_requests_status_check;
|
|
33
|
+
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_status_check CHECK (status IN ('open', 'granted', 'dismissed', 'expired'));
|
|
34
|
+
|
|
35
|
+
-- C0 controls, DEL and C1 controls (Unicode Cc); NUL cannot be stored in text at all.
|
|
36
|
+
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_invoice_name_printable CHECK (invoice_name !~ '[\x01-\x1f\x7f-\x9f]') NOT VALID;
|
|
37
|
+
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_invoice_tax_id_printable CHECK (invoice_tax_id !~ '[\x01-\x1f\x7f-\x9f]') NOT VALID;
|
|
38
|
+
ALTER TABLE payment_requests ADD CONSTRAINT payment_requests_invoice_address_printable CHECK (invoice_address !~ '[\x01-\x1f\x7f-\x9f]') NOT VALID;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
-- Failed refunds: a provider refund that fails after billing acted on it (Stripe `refund.failed`)
|
|
2
|
+
-- gives back what it took. Each failure is stored once per refund id, so a repeated delivery gives
|
|
3
|
+
-- back nothing twice. A payment keeps the local days refunds took from its period
|
|
4
|
+
-- (`taken_back_days`), so a failure can give them back, and the time of the newest charge snapshot
|
|
5
|
+
-- it recorded (`refunds_seen_at`, the event's `created`), so a failure of a refund billing never
|
|
6
|
+
-- counted gives back nothing and a snapshot taken before a failure is corrected by it.
|
|
7
|
+
-- Rollback: DROP TABLE billing.refund_failures;
|
|
8
|
+
-- ALTER TABLE billing.payments DROP COLUMN taken_back_days, DROP COLUMN refunds_seen_at; then
|
|
9
|
+
-- DELETE FROM softure.migrations WHERE module = 'billing' AND version = 7;
|
|
10
|
+
ALTER TABLE payments ADD COLUMN taken_back_days integer NOT NULL DEFAULT 0 CHECK (taken_back_days >= 0);
|
|
11
|
+
ALTER TABLE payments ADD COLUMN refunds_seen_at timestamptz;
|
|
12
|
+
|
|
13
|
+
-- Refunds recorded before this migration were seen when they were recorded (or before now).
|
|
14
|
+
UPDATE payments SET refunds_seen_at = COALESCE(refunded_at, now()) WHERE refunded_amount > 0;
|
|
15
|
+
|
|
16
|
+
CREATE TABLE refund_failures (
|
|
17
|
+
payment_id uuid NOT NULL REFERENCES payments (id) ON DELETE CASCADE,
|
|
18
|
+
-- The provider's refund id (a Stripe Refund, `re_...`).
|
|
19
|
+
refund_id text NOT NULL CHECK (length(refund_id) BETWEEN 1 AND 255),
|
|
20
|
+
-- What the refund was for, in the currency's minor unit.
|
|
21
|
+
amount bigint NOT NULL CHECK (amount >= 0),
|
|
22
|
+
-- When the provider created the refund, and when it reported the failure (the event's time).
|
|
23
|
+
refund_created_at timestamptz NOT NULL,
|
|
24
|
+
failed_at timestamptz NOT NULL,
|
|
25
|
+
recorded_at timestamptz NOT NULL,
|
|
26
|
+
PRIMARY KEY (payment_id, refund_id)
|
|
27
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- A hand-over left without an answer (the process stopped between the claim and `onRequest`'s
|
|
2
|
+
-- answer) is handed over again by a later ask. The claim moves to its own column,
|
|
3
|
+
-- handover_claimed_at, which blocks other asks for a bounded time only; handed_over_at now records
|
|
4
|
+
-- a hand-over that answered Ok and is never repeated. Rows from before keep handed_over_at: they
|
|
5
|
+
-- count as handed over (a claim left behind before this migration cannot be told apart).
|
|
6
|
+
-- Rollback: ALTER TABLE billing.payment_requests DROP COLUMN handover_claimed_at; then
|
|
7
|
+
-- DELETE FROM softure.migrations WHERE module = 'billing' AND version = 8;
|
|
8
|
+
-- (the previous code reads handed_over_at as a claim again: nothing is handed over twice).
|
|
9
|
+
ALTER TABLE payment_requests ADD COLUMN handover_claimed_at timestamptz;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
-- Pending charge states: Stripe does not order events, so a new refund's `charge.refunded` can arrive
|
|
2
|
+
-- before the failure of an earlier refund of the same charge. Its `amount_refunded` then reports no
|
|
3
|
+
-- more than billing counts, and applying it would undo nothing billing knows of. A payment keeps the
|
|
4
|
+
-- newest such state it did not apply (the raw total and the event's `created`), so the failure that
|
|
5
|
+
-- explains it applies it after giving back the failed refund: the new refund is taken back once.
|
|
6
|
+
-- Applying a state at or after the kept one's time clears it.
|
|
7
|
+
-- Rollback: ALTER TABLE billing.payments DROP CONSTRAINT payments_pending_charge_state_shape;
|
|
8
|
+
-- ALTER TABLE billing.payments DROP COLUMN pending_refunded_amount, DROP COLUMN pending_refunds_seen_at; then
|
|
9
|
+
-- DELETE FROM softure.migrations WHERE module = 'billing' AND version = 9;
|
|
10
|
+
ALTER TABLE payments ADD COLUMN pending_refunded_amount bigint;
|
|
11
|
+
ALTER TABLE payments ADD COLUMN pending_refunds_seen_at timestamptz;
|
|
12
|
+
|
|
13
|
+
ALTER TABLE payments ADD CONSTRAINT payments_pending_charge_state_shape CHECK (
|
|
14
|
+
(pending_refunded_amount IS NULL AND pending_refunds_seen_at IS NULL)
|
|
15
|
+
OR (pending_refunded_amount IS NOT NULL AND pending_refunded_amount >= 0 AND pending_refunds_seen_at IS NOT NULL)
|
|
16
|
+
);
|
package/module.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "billing",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"dependsOn": { "security": "^0.1.0", "auth": "^0.1.0" },
|
|
5
|
+
"dbSchema": "billing",
|
|
6
|
+
"tables": ["entitlements", "payments", "payment_requests", "manual_grants"],
|
|
7
|
+
"env": [
|
|
8
|
+
{
|
|
9
|
+
"name": "STRIPE_SECRET_KEY",
|
|
10
|
+
"required": false,
|
|
11
|
+
"description": "Secret API key of the stripe() payment adapter (sk_test_... in the sandbox), read on every payment; not needed with stripe({ secretKey }) or another adapter."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "STRIPE_WEBHOOK_SECRET",
|
|
15
|
+
"required": false,
|
|
16
|
+
"description": "Signing secret (whsec_...) of the Stripe webhook endpoint that points at stripeWebhookRoute; required when that route is mounted."
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"switches": [],
|
|
20
|
+
"routes": { "payment": "/payment", "admin": "/admin/billing", "webhook": "/api/billing/webhook" },
|
|
21
|
+
"mount": [{ "kind": "route-handler", "path": "app/api/billing/webhook/route.ts", "export": "stripeWebhookRoute" }],
|
|
22
|
+
"privacy": { "exports": true, "deletes": true }
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,83 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/billing",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "SOFTURE AI billing module: trial, paid and read-only entitlements from a pure state machine, billing.entitlements apart from auth.users, a write guard for server actions, access badge and notice components, a privacy contributor, reminder mail before access ends over @softure-ai/mailing, manual invoice requests handed to the owner once and expired after a configurable age, grant-plan and revoke-grant ops scripts, and import-entitlements and pin-trials ops scripts for accounts that exist before billing is turned on.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=22"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/SOFTURE/AI.git",
|
|
14
|
+
"directory": "modules/billing"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src",
|
|
19
|
+
"!src/**/*.test.ts",
|
|
20
|
+
"!src/**/*.test.tsx",
|
|
21
|
+
"migrations",
|
|
22
|
+
"module.json"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"provenance": true
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"@softure-ai/source": "./src/index.ts",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./server": {
|
|
35
|
+
"@softure-ai/source": "./src/server/index.ts",
|
|
36
|
+
"types": "./dist/server/index.d.ts",
|
|
37
|
+
"default": "./dist/server/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./mailing": {
|
|
40
|
+
"@softure-ai/source": "./src/mailing/index.ts",
|
|
41
|
+
"types": "./dist/mailing/index.d.ts",
|
|
42
|
+
"default": "./dist/mailing/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./next": {
|
|
45
|
+
"@softure-ai/source": "./src/next/index.ts",
|
|
46
|
+
"types": "./dist/next/index.d.ts",
|
|
47
|
+
"default": "./dist/next/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./scripts": {
|
|
50
|
+
"@softure-ai/source": "./src/scripts/index.ts",
|
|
51
|
+
"types": "./dist/scripts/index.d.ts",
|
|
52
|
+
"default": "./dist/scripts/index.js"
|
|
53
|
+
},
|
|
54
|
+
"./ui": {
|
|
55
|
+
"@softure-ai/source": "./src/ui/index.ts",
|
|
56
|
+
"types": "./dist/ui/index.d.ts",
|
|
57
|
+
"default": "./dist/ui/index.js"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"build": "tsc -p tsconfig.build.json"
|
|
62
|
+
},
|
|
63
|
+
"dependencies": {
|
|
64
|
+
"@softure-ai/auth": "^0.1.0",
|
|
65
|
+
"@softure-ai/core": "^0.1.0",
|
|
66
|
+
"@softure-ai/db": "^0.1.0",
|
|
67
|
+
"@softure-ai/ops": "^0.1.0",
|
|
68
|
+
"@softure-ai/security": "^0.1.0",
|
|
69
|
+
"@softure-ai/ui": "^0.1.0",
|
|
70
|
+
"zod": "^4.6.5"
|
|
71
|
+
},
|
|
72
|
+
"peerDependencies": {
|
|
73
|
+
"@softure-ai/mailing": "^0.1.0",
|
|
74
|
+
"drizzle-orm": "^0.45.2",
|
|
75
|
+
"next": "^16.0.0",
|
|
76
|
+
"react": "^19.0.0"
|
|
77
|
+
},
|
|
78
|
+
"peerDependenciesMeta": {
|
|
79
|
+
"@softure-ai/mailing": {
|
|
80
|
+
"optional": true
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/calendar.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Calendar days in the app's IANA time zone, without a date library: trials end at the start of a
|
|
2
|
+
// local day and "days left" counts local days, as people do.
|
|
3
|
+
|
|
4
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
const formatters = new Map<string, Intl.DateTimeFormat>();
|
|
7
|
+
|
|
8
|
+
function getFormatter(timezone: string): Intl.DateTimeFormat {
|
|
9
|
+
let format = formatters.get(timezone);
|
|
10
|
+
if (format === undefined) {
|
|
11
|
+
format = new Intl.DateTimeFormat("en-CA", {
|
|
12
|
+
timeZone: timezone,
|
|
13
|
+
hourCycle: "h23",
|
|
14
|
+
year: "numeric",
|
|
15
|
+
month: "2-digit",
|
|
16
|
+
day: "2-digit",
|
|
17
|
+
hour: "2-digit",
|
|
18
|
+
minute: "2-digit",
|
|
19
|
+
second: "2-digit",
|
|
20
|
+
});
|
|
21
|
+
formatters.set(timezone, format);
|
|
22
|
+
}
|
|
23
|
+
return format;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The local wall-clock time of an instant, read as if it were UTC (milliseconds). */
|
|
27
|
+
function getLocalWallTime(instant: number, timezone: string): number {
|
|
28
|
+
const parts = Object.fromEntries(
|
|
29
|
+
getFormatter(timezone)
|
|
30
|
+
.formatToParts(instant)
|
|
31
|
+
.map((part) => [part.type, part.value]),
|
|
32
|
+
);
|
|
33
|
+
return Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The calendar day of an instant in a time zone, as a day count; two of them subtract to days. */
|
|
37
|
+
export function getDayNumber(instant: Date, timezone: string): number {
|
|
38
|
+
return Math.floor(getLocalWallTime(instant.getTime(), timezone) / DAY_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The instant a local day starts (00:00 there). Two passes settle the offset across a DST change.
|
|
45
|
+
* In a zone that skips midnight itself (America/Santiago, America/Havana) 00:00 does not exist and
|
|
46
|
+
* the passes land in the previous day, so the result moves on by hours to the first instant of
|
|
47
|
+
* the day: 01:00 after a one-hour gap.
|
|
48
|
+
*/
|
|
49
|
+
export function getStartOfDay(dayNumber: number, timezone: string): Date {
|
|
50
|
+
const wallTime = dayNumber * DAY_MS;
|
|
51
|
+
let instant = wallTime;
|
|
52
|
+
for (let pass = 0; pass < 2; pass += 1) instant = wallTime - (getLocalWallTime(instant, timezone) - instant);
|
|
53
|
+
for (let step = 0; step < 3 && getDayNumber(new Date(instant), timezone) < dayNumber; step += 1) {
|
|
54
|
+
instant = Math.floor(instant / HOUR_MS) * HOUR_MS + HOUR_MS;
|
|
55
|
+
}
|
|
56
|
+
return new Date(instant);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The end of a trial of `days` days begun at `start`: the start of the local day `days` days after
|
|
61
|
+
* the start day. The start day counts as the first day, so a 14-day trial begun on 3 October ends
|
|
62
|
+
* when 17 October begins. Zero days ends it at the start of the start day: no trial.
|
|
63
|
+
*/
|
|
64
|
+
export function getTrialEnd(start: Date, days: number, timezone: string): Date {
|
|
65
|
+
return getStartOfDay(getDayNumber(start, timezone) + days, timezone);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Local calendar days of access left before `end`, today included: 1 on the last day. Assumes
|
|
70
|
+
* `end` is after `now`.
|
|
71
|
+
*/
|
|
72
|
+
export function getDaysLeft(end: Date, now: Date, timezone: string): number {
|
|
73
|
+
return getDayNumber(new Date(end.getTime() - 1), timezone) - getDayNumber(now, timezone) + 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The day number (as `getDayNumber` counts) of a `YYYY-MM-DD` calendar day, or null when the text
|
|
80
|
+
* is not one (`2026-1-1`, `2026-02-30`).
|
|
81
|
+
*/
|
|
82
|
+
export function parseDay(day: string): number | null {
|
|
83
|
+
const match = DAY_PATTERN.exec(day);
|
|
84
|
+
if (match === null) return null;
|
|
85
|
+
const [year, month, date] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
86
|
+
const wallTime = Date.UTC(year, month - 1, date);
|
|
87
|
+
const parsed = new Date(wallTime);
|
|
88
|
+
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== date) return null;
|
|
89
|
+
return wallTime / DAY_MS;
|
|
90
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Result types and error codes of the billing module. No user-facing copy here: the UI translates
|
|
2
|
+
// codes through `messages` (docs/02-module-standard.md §6).
|
|
3
|
+
import type { CoreErrorCode, Locale } from "@softure-ai/core";
|
|
4
|
+
import type { InvoiceFieldErrorCode } from "./invoice.js";
|
|
5
|
+
|
|
6
|
+
export type BillingErrorCode =
|
|
7
|
+
/** The account may read but not write: its trial or its paid access ended. */
|
|
8
|
+
| "billing.read_only"
|
|
9
|
+
/** No account has this id. */
|
|
10
|
+
| "billing.account_unknown"
|
|
11
|
+
/** A grant or a trial extension that ends now or earlier. */
|
|
12
|
+
| "billing.end_not_in_future";
|
|
13
|
+
|
|
14
|
+
/** Every code a guarded write can show: the guard's own and the generic ones. */
|
|
15
|
+
export type BillingFormErrorCode = BillingErrorCode | CoreErrorCode;
|
|
16
|
+
|
|
17
|
+
export const ENTITLEMENT_STATUSES = ["trial", "paid", "read_only"] as const;
|
|
18
|
+
|
|
19
|
+
export type EntitlementStatus = (typeof ENTITLEMENT_STATUSES)[number];
|
|
20
|
+
|
|
21
|
+
/** What is stored per account (or derived for an account without a row). */
|
|
22
|
+
export interface EntitlementRecord {
|
|
23
|
+
/** The first instant the trial no longer covers. */
|
|
24
|
+
readonly trialEndsAt: Date;
|
|
25
|
+
/**
|
|
26
|
+
* The first instant dated paid access no longer covers; null when never paid or revoked. Kept
|
|
27
|
+
* under lifetime access, so a refunded lifetime falls back to the periods bought beside it.
|
|
28
|
+
*/
|
|
29
|
+
readonly paidUntil: Date | null;
|
|
30
|
+
/** Paid access without an end; it wins over `paidUntil`. */
|
|
31
|
+
readonly isLifetime: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where an account stands at one instant: the state machine's answer. */
|
|
35
|
+
export type Entitlement =
|
|
36
|
+
| {
|
|
37
|
+
readonly status: "trial";
|
|
38
|
+
readonly endsAt: Date;
|
|
39
|
+
/** Calendar days of access left in the app's time zone, today included. */
|
|
40
|
+
readonly daysLeft: number;
|
|
41
|
+
/** Inside the trial reminder window: time to show the notice. */
|
|
42
|
+
readonly isEnding: boolean;
|
|
43
|
+
}
|
|
44
|
+
| {
|
|
45
|
+
readonly status: "paid";
|
|
46
|
+
/** Null for lifetime access. */
|
|
47
|
+
readonly endsAt: Date | null;
|
|
48
|
+
/** Null for lifetime access. */
|
|
49
|
+
readonly daysLeft: number | null;
|
|
50
|
+
/** Inside the paid reminder window; never for lifetime access. */
|
|
51
|
+
readonly isEnding: boolean;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
readonly status: "read_only";
|
|
55
|
+
/** When write access ended. */
|
|
56
|
+
readonly since: Date;
|
|
57
|
+
/** Which access ended last. */
|
|
58
|
+
readonly reason: "trial_ended" | "paid_ended";
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** A change to an account's entitlement, applied by `changeEntitlement`. */
|
|
62
|
+
export type EntitlementEvent =
|
|
63
|
+
/** Paid access until `until`; never shortens a later end already granted. */
|
|
64
|
+
| { readonly type: "grant"; readonly until: Date }
|
|
65
|
+
/** Paid access without an end; the dated end stays beside it. */
|
|
66
|
+
| { readonly type: "grant_lifetime" }
|
|
67
|
+
/** Removes all paid access (a mistaken grant), lifetime included; the trial stays as it was. */
|
|
68
|
+
| { readonly type: "revoke" }
|
|
69
|
+
/**
|
|
70
|
+
* Moves dated paid access back to end at `until` (a refunded period); never lengthens it. An end
|
|
71
|
+
* at or before the trial's end drops dated paid access: the account is back on its trial.
|
|
72
|
+
*/
|
|
73
|
+
| { readonly type: "shorten"; readonly until: Date }
|
|
74
|
+
/** Ends lifetime access (a refunded lifetime); dated paid access stays. */
|
|
75
|
+
| { readonly type: "end_lifetime" }
|
|
76
|
+
/** Moves the trial end to `until`; never shortens it. */
|
|
77
|
+
| { readonly type: "extend_trial"; readonly until: Date }
|
|
78
|
+
/**
|
|
79
|
+
* Merges a record carried over from another system (`importEntitlement`): each end only moves
|
|
80
|
+
* later and lifetime only turns on, so an import never takes access away and a repeat changes
|
|
81
|
+
* nothing. Ends may lie in the past: an ended trial or period is recorded as it was. Null keeps
|
|
82
|
+
* what the account has.
|
|
83
|
+
*/
|
|
84
|
+
| { readonly type: "import"; readonly trialEndsAt: Date | null; readonly paidUntil: Date | null; readonly isLifetime: boolean };
|
|
85
|
+
|
|
86
|
+
/** What one payment of a plan added to an account, stored with the payment so a refund takes back only that. */
|
|
87
|
+
export type PaymentGrant =
|
|
88
|
+
/** A paid period from where access ended (or the payment's instant) to the period's end. */
|
|
89
|
+
| { readonly kind: "period"; readonly from: Date; readonly until: Date }
|
|
90
|
+
| { readonly kind: "lifetime" };
|
|
91
|
+
|
|
92
|
+
/** Copy per locale; a locale without its own text falls back to `en`. */
|
|
93
|
+
export type LocalizedText = Readonly<Partial<Record<Locale, string>>>;
|
|
94
|
+
|
|
95
|
+
export const PERIOD_UNITS = ["day", "week", "month", "year"] as const;
|
|
96
|
+
|
|
97
|
+
export type PeriodUnit = (typeof PERIOD_UNITS)[number];
|
|
98
|
+
|
|
99
|
+
/** How long one payment of a plan gives access: `count` units, or for good. */
|
|
100
|
+
export type PlanPeriod = { readonly unit: PeriodUnit; readonly count: number } | { readonly unit: "lifetime" };
|
|
101
|
+
|
|
102
|
+
/** A price in the currency's minor unit (cents, grosze; whole yen for JPY), as payment providers take it. */
|
|
103
|
+
export interface PlanPrice {
|
|
104
|
+
readonly amount: number;
|
|
105
|
+
/** ISO 4217, upper case, e.g. `PLN`, `EUR`. */
|
|
106
|
+
readonly currency: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A plan from `billing({ plans })`. */
|
|
110
|
+
export interface Plan {
|
|
111
|
+
/** Kebab-case, unique; the payment page and the payment providers name the plan by it. */
|
|
112
|
+
readonly id: string;
|
|
113
|
+
readonly name: LocalizedText;
|
|
114
|
+
readonly description?: LocalizedText;
|
|
115
|
+
readonly price: PlanPrice;
|
|
116
|
+
readonly period: PlanPeriod;
|
|
117
|
+
/** What the plan includes, one line each, in the order the tile lists them. */
|
|
118
|
+
readonly features: readonly LocalizedText[];
|
|
119
|
+
/** Drawn as the recommended plan. */
|
|
120
|
+
readonly isFeatured: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Why a payment could not start. */
|
|
124
|
+
export type PaymentErrorCode =
|
|
125
|
+
/** A plan id the config does not declare: a tampered or outdated form. */
|
|
126
|
+
| "billing.plan_unknown"
|
|
127
|
+
/** An invoice field is missing, too long or has control characters; `fieldErrors` says which. */
|
|
128
|
+
| "billing.invoice_details_invalid"
|
|
129
|
+
/** The payment provider refused or failed; nothing was charged or requested. */
|
|
130
|
+
| "billing.payment_failed"
|
|
131
|
+
/** The account has lifetime access: there is nothing left to pay for or grant. */
|
|
132
|
+
| "billing.lifetime_active";
|
|
133
|
+
|
|
134
|
+
/** Every code the payment form can show, the invoice fields' own included. */
|
|
135
|
+
export type PaymentFormErrorCode = PaymentErrorCode | InvoiceFieldErrorCode | "security.rate_limited" | CoreErrorCode;
|
|
136
|
+
|
|
137
|
+
/** Every code the admin grant form can show. */
|
|
138
|
+
export type GrantFormErrorCode = "billing.plan_unknown" | "billing.account_unknown" | "billing.lifetime_active" | "auth.forbidden" | CoreErrorCode;
|
|
139
|
+
|
|
140
|
+
/** Why an admin could not act on a request or a manual grant. */
|
|
141
|
+
export type AdminErrorCode =
|
|
142
|
+
/** The request was granted or dismissed already, or never existed. */
|
|
143
|
+
| "billing.request_closed"
|
|
144
|
+
/** The grant was revoked already, or never existed. */
|
|
145
|
+
| "billing.grant_revoked";
|
|
146
|
+
|
|
147
|
+
/** Every code the admin page's buttons and account lookup can show. */
|
|
148
|
+
export type AdminActionErrorCode = AdminErrorCode | GrantFormErrorCode;
|
|
149
|
+
|
|
150
|
+
/** What the payment action returns to its form (`useActionState`). */
|
|
151
|
+
export interface PaymentFormState {
|
|
152
|
+
readonly status: "idle" | "requested" | "error";
|
|
153
|
+
readonly error?: PaymentFormErrorCode;
|
|
154
|
+
/** Errors of single invoice fields, by field name. */
|
|
155
|
+
readonly fieldErrors?: Readonly<Record<string, PaymentFormErrorCode>>;
|
|
156
|
+
/** The invoice details as typed, to fill the fields again after an error. */
|
|
157
|
+
readonly values?: Readonly<Record<string, string>>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const INITIAL_PAYMENT_FORM_STATE: PaymentFormState = { status: "idle" };
|
|
161
|
+
|
|
162
|
+
/** What the admin grant action returns to its form (`useActionState`). */
|
|
163
|
+
export interface GrantFormState {
|
|
164
|
+
readonly status: "idle" | "granted" | "error";
|
|
165
|
+
readonly error?: GrantFormErrorCode;
|
|
166
|
+
/** After a grant: what the account has now, in the app's copy. */
|
|
167
|
+
readonly notice?: string;
|
|
168
|
+
/** The email and plan as sent, to fill the form again after an error. */
|
|
169
|
+
readonly email?: string;
|
|
170
|
+
readonly planId?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const INITIAL_GRANT_FORM_STATE: GrantFormState = { status: "idle" };
|
|
174
|
+
|
|
175
|
+
/** What an admin button (grant or dismiss a request, revoke a grant, find an account) returns to its form. */
|
|
176
|
+
export type AdminActionState =
|
|
177
|
+
| { readonly status: "idle" }
|
|
178
|
+
| { readonly status: "done" }
|
|
179
|
+
| { readonly status: "error"; readonly error: AdminActionErrorCode; readonly email?: string };
|
|
180
|
+
|
|
181
|
+
export const INITIAL_ADMIN_ACTION_STATE: AdminActionState = { status: "idle" };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The minor unit billing pins for each currency it accepts: a plan's `price.amount` counts these
|
|
2
|
+
// units on every runtime, whatever the runtime's `Intl` (CLDR) says. Source: ISO 4217 List One,
|
|
3
|
+
// published 2024-06-25, without fund codes, `N.A.` units (metals, XDR, XTS, XXX ...) and UYW (a wage
|
|
4
|
+
// index unit), plus the two choices below. CLDR differs from ISO on AFN, ALL, IRR, KPW, LAK, LBP,
|
|
5
|
+
// MMK, RSD, SOS, SYP and YER (0 instead of 2) and IQD (0 instead of 3), and its HUF and TWD digits
|
|
6
|
+
// have changed between Node builds; billing follows ISO, as Stripe does for all but MGA.
|
|
7
|
+
|
|
8
|
+
/** ISO 4217 codes by the digits of their minor unit. */
|
|
9
|
+
const CODES_BY_DIGITS: Readonly<Record<0 | 2 | 3, readonly string[]>> = {
|
|
10
|
+
0: [
|
|
11
|
+
"BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF",
|
|
12
|
+
// Override: ISO writes 2, but one ariary is five iraimbilanja (not a decimal subunit), and CLDR
|
|
13
|
+
// and Stripe both count it without a minor unit.
|
|
14
|
+
"MGA",
|
|
15
|
+
],
|
|
16
|
+
2: [
|
|
17
|
+
"AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BMD", "BND",
|
|
18
|
+
"BOB", "BRL", "BSD", "BTN", "BWP", "BYN", "BZD", "CAD", "CDF", "CHF", "CNY", "COP", "CRC", "CUC", "CUP", "CVE",
|
|
19
|
+
"CZK", "DKK", "DOP", "DZD", "EGP", "ERN", "ETB", "EUR", "FJD", "FKP", "GBP", "GEL", "GHS", "GIP", "GMD", "GTQ",
|
|
20
|
+
"GYD", "HKD", "HNL", "HTG", "HUF", "IDR", "ILS", "INR", "IRR", "JMD", "KES", "KGS", "KHR", "KPW", "KYD", "KZT",
|
|
21
|
+
"LAK", "LBP", "LKR", "LRD", "LSL", "MAD", "MDL", "MKD", "MMK", "MNT", "MOP", "MRU", "MUR", "MVR", "MWK", "MXN",
|
|
22
|
+
"MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "PAB", "PEN", "PGK", "PHP", "PKR", "PLN", "QAR", "RON",
|
|
23
|
+
"RSD", "RUB", "SAR", "SBD", "SCR", "SDG", "SEK", "SGD", "SHP", "SLE", "SOS", "SRD", "SSP", "STN", "SVC", "SYP",
|
|
24
|
+
"SZL", "THB", "TJS", "TMT", "TOP", "TRY", "TTD", "TWD", "TZS", "UAH", "USD", "UYU", "UZS", "VED", "VES", "WST",
|
|
25
|
+
"XCD", "YER", "ZAR", "ZMW", "ZWG",
|
|
26
|
+
// Addition: the Caribbean guilder, which replaced ANG on 2025-03-31, after the pinned list.
|
|
27
|
+
"XCG",
|
|
28
|
+
],
|
|
29
|
+
3: ["BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Digits of the minor unit per currency code billing accepts, e.g. `PLN: 2`, `JPY: 0`, `KWD: 3`. */
|
|
33
|
+
export const CURRENCY_MINOR_UNIT_DIGITS: Readonly<Record<string, number>> = Object.freeze(
|
|
34
|
+
Object.fromEntries(
|
|
35
|
+
Object.entries(CODES_BY_DIGITS).flatMap(([digits, codes]) => codes.map((code) => [code, Number(digits)] as const)),
|
|
36
|
+
),
|
|
37
|
+
);
|