@backendfree/payments 0.0.0-stage → 0.1.0

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/src/index.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @backendfree/payments
3
+ *
4
+ * A project's money: what has been taken, and taking some.
5
+ *
6
+ * import { Client } from '@backendfree/core';
7
+ * import { Payments } from '@backendfree/payments';
8
+ *
9
+ * const payments = new Payments(new Client({ origin, key: process.env.SECRET_KEY }));
10
+ * const payment = await payments.checkout({ amount: 3500, success_url, cancel_url });
11
+ * redirect(payment.checkout_url);
12
+ *
13
+ * Four things are worth knowing before using it.
14
+ *
15
+ * **All of it needs a secret key, and this throws on a publishable one.** A
16
+ * payment carries the paying customer's name and email, and a checkout is a
17
+ * charge on somebody's bank account. Neither belongs in a bundle whoever loads
18
+ * the site can read, so the mistake is refused at construction rather than by a
19
+ * 403 in production.
20
+ *
21
+ * **There is no refund method, and that is deliberate.** Refunding is the one
22
+ * act that moves money out of the business's own account, and it lives in the
23
+ * payments screen in the dashboard, behind a capability only the account owner
24
+ * holds. A leaked key that can read payments is one incident; one that could
25
+ * refund would drain the account. See the README.
26
+ *
27
+ * **Nothing here says a payment was made.** A checkout is a page that exists.
28
+ * The provider's webhook is what says money moved, and the page the customer
29
+ * lands on afterwards says nothing at all. Read `payment.status` back, or
30
+ * better, take the platform's `payment.succeeded` webhook and verify it with
31
+ * `verifyWebhook` from core.
32
+ *
33
+ * **Money is minor units and a currency, everywhere.** 3500 is CHF 35.00.
34
+ * `formatMoney` turns that into something to show a person, and dividing by a
35
+ * hundred yourself is wrong in both directions depending on the currency.
36
+ */
37
+
38
+ import { ConfigError } from '@backendfree/core';
39
+ import type { Client, Page as Paginated, RequestOptions } from '@backendfree/core';
40
+
41
+ import type { CheckoutRequest, Money, Payment } from './types.js';
42
+
43
+ /** Which payments to list. Every filter is optional and they combine. */
44
+ export interface PaymentQuery extends RequestOptions {
45
+ status?: string;
46
+ /** For example `bookings.booking`. */
47
+ subject_type?: string;
48
+ /** That object's id, to answer "was this one paid for?". */
49
+ subject_id?: string;
50
+ }
51
+
52
+ /** Where to send the customer, when offering a page for a payment that exists. */
53
+ export interface OfferOptions extends RequestOptions {
54
+ success_url: string;
55
+ cancel_url: string;
56
+ description?: string;
57
+ }
58
+
59
+ export class Payments {
60
+ readonly #client: Client;
61
+
62
+ constructor(client: Client) {
63
+ // Refused here rather than by the API, because the answer is the same
64
+ // either way and finding out at construction is a stack trace pointing at
65
+ // the line that is wrong.
66
+ if (client.publishable) {
67
+ throw new ConfigError(
68
+ 'secret_key_required',
69
+ 'Payments needs a secret key. A payment carries a customer name and email and a ' +
70
+ 'checkout is a charge on a bank account, so none of it is safe in a browser.',
71
+ );
72
+ }
73
+ this.#client = client;
74
+ }
75
+
76
+ // --- what has been taken -----------------------------------------------------
77
+
78
+ /**
79
+ * Payments, newest first.
80
+ *
81
+ * Not cached, on purpose: a payment moves when the provider says so, and a
82
+ * stored answer would outlive the money it describes.
83
+ */
84
+ list(query: PaymentQuery = {}): Promise<Paginated<Payment>> {
85
+ const { status, subject_type, subject_id, ...rest } = query;
86
+ return this.#client.get<Paginated<Payment>>('/payments', {
87
+ ...rest,
88
+ query: { status, subject_type, subject_id, ...rest.query },
89
+ });
90
+ }
91
+
92
+ payment(id: string, options: RequestOptions = {}): Promise<Payment> {
93
+ return this.#client.get<Payment>(`/payments/${encodeURIComponent(id)}`, options);
94
+ }
95
+
96
+ /**
97
+ * Whether something on the platform has been paid for, in one call.
98
+ *
99
+ * What a booking confirmation page asks. Returns the payments against that
100
+ * object, because there can be more than one: a deposit and a balance, or a
101
+ * first attempt that failed and a second that did not.
102
+ */
103
+ async forSubject(
104
+ subject_type: string,
105
+ subject_id: string,
106
+ options: RequestOptions = {},
107
+ ): Promise<Payment[]> {
108
+ const { results } = await this.list({ ...options, subject_type, subject_id });
109
+ return results;
110
+ }
111
+
112
+ // --- taking some -------------------------------------------------------------
113
+
114
+ /**
115
+ * Open a payment page for one amount.
116
+ *
117
+ * An invoice, a deposit taken over the phone, a donation button. It settles
118
+ * nothing on the platform and names no booking, which is why it cannot be
119
+ * used to mark somebody else's appointment paid.
120
+ *
121
+ * Retrying this call is safe: core puts an idempotency key on it, and the
122
+ * same key is always the same charge. Pass your own order id as
123
+ * `idempotencyKey` to make a retry from another process count as the same
124
+ * request.
125
+ *
126
+ * A provider that refuses does not fail the call. The payment comes back with
127
+ * an empty `checkout_url` and the reason in `failure_message`, so the attempt
128
+ * is on record and you have an id to try again with. `offerAgain` is what
129
+ * tries again; calling this a second time would be a second payment for one
130
+ * order.
131
+ */
132
+ checkout(request: CheckoutRequest, options: RequestOptions = {}): Promise<Payment> {
133
+ return this.#client.post<Payment>('/checkouts', request, options);
134
+ }
135
+
136
+ /**
137
+ * Offer the page again for a payment that already exists.
138
+ *
139
+ * What a customer needs when the provider refused, or when they closed the
140
+ * tab. Keyed on the payment rather than on a fresh idempotency key, so asking
141
+ * twice is one page.
142
+ *
143
+ * What is owed comes off the payment, never off this call: the price was
144
+ * agreed when the payment was made. A payment that is settled or over is
145
+ * answered rather than re-opened, because re-opening one is how somebody pays
146
+ * twice.
147
+ */
148
+ offerAgain(id: string, options: OfferOptions): Promise<Payment> {
149
+ const { success_url, cancel_url, description, ...rest } = options;
150
+ return this.#client.post<Payment>(
151
+ `/payments/${encodeURIComponent(id)}/checkout`,
152
+ { success_url, cancel_url, description },
153
+ rest,
154
+ );
155
+ }
156
+ }
157
+
158
+ // --- reading a payment ---------------------------------------------------------
159
+
160
+ /** Whether the money arrived. True of a refunded payment too: it did arrive. */
161
+ export function hasPaid(payment: Payment): boolean {
162
+ return (
163
+ payment.status === 'paid' ||
164
+ payment.status === 'partially_refunded' ||
165
+ payment.status === 'refunded'
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Whether nothing further will happen to this payment.
171
+ *
172
+ * What decides whether offering the page again is worth anything. A failed,
173
+ * cancelled, expired or fully refunded payment is over; a created or pending
174
+ * one can still be paid.
175
+ */
176
+ export function isOver(payment: Payment): boolean {
177
+ return (
178
+ payment.status === 'failed' ||
179
+ payment.status === 'cancelled' ||
180
+ payment.status === 'expired' ||
181
+ payment.status === 'refunded'
182
+ );
183
+ }
184
+
185
+ /**
186
+ * An amount, written the way a person in a given place writes it.
187
+ *
188
+ * The one formatting helper worth shipping, for the same reason bookings ships
189
+ * one for time: the obvious version is wrong. Dividing by a hundred is right
190
+ * for CHF, wrong for JPY, which has no minor unit at all, and wrong the other
191
+ * way for KWD, which has three. The number of places comes from the currency
192
+ * rather than from a constant, so this is correct for currencies nobody thought
193
+ * about when writing it.
194
+ *
195
+ * `locales` defaults to the reader's own. Pass one to match a page's language.
196
+ */
197
+ export function formatMoney(money: Money, locales?: string | string[]): string {
198
+ const format = new Intl.NumberFormat(locales, { style: 'currency', currency: money.currency });
199
+ const places = format.resolvedOptions().maximumFractionDigits ?? 2;
200
+ return format.format(money.amount / 10 ** places);
201
+ }
202
+
203
+ export type {
204
+ CheckoutRequest,
205
+ Money,
206
+ Payment,
207
+ PaymentStatus,
208
+ Refund,
209
+ RefundStatus,
210
+ } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * What `/v1` returns for payments, as the wire returns it.
3
+ *
4
+ * Money is an object rather than a bare number: `{amount: 3500, currency:
5
+ * "CHF"}` says what 3500 means. A bare number invites dividing by a hundred that
6
+ * somebody guessed at, which is wrong for JPY and wrong for KWD in the other
7
+ * direction. `formatMoney` does it properly.
8
+ *
9
+ * Three things a payment deliberately does not say, and their absence is the
10
+ * product rather than an omission. The provider account the money landed in is
11
+ * the business's alone and appears nowhere. Neither do the credentials behind it.
12
+ * Neither does the provider's own name or its session and charge references,
13
+ * because nothing a receipt renders needs them.
14
+ */
15
+
16
+ export interface Money {
17
+ /** Minor units: 3500 is CHF 35.00. Never divide without the currency. */
18
+ amount: number;
19
+ currency: string;
20
+ }
21
+
22
+ /**
23
+ * Where one payment has got to.
24
+ *
25
+ * It only ever moves forward. A provider redelivers events for days and
26
+ * promises no order, so the platform applies a status as a maximum rather than
27
+ * as an assignment, and a late "still pending" cannot undo a payment that
28
+ * arrived.
29
+ *
30
+ * `created` is a payment whose page was never opened, which is a provider that
31
+ * refused rather than a customer who declined.
32
+ */
33
+ export type PaymentStatus =
34
+ | 'created'
35
+ | 'pending'
36
+ | 'requires_action'
37
+ | 'paid'
38
+ | 'failed'
39
+ | 'cancelled'
40
+ | 'expired'
41
+ | 'partially_refunded'
42
+ | 'refunded';
43
+
44
+ /**
45
+ * Where one refund has got to.
46
+ *
47
+ * Its own vocabulary rather than a corner of the payment's, because a refund
48
+ * succeeding and a payment being refunded are different facts: a payment can
49
+ * carry several refunds, and one of them failing does not move the payment.
50
+ */
51
+ export type RefundStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled';
52
+
53
+ export interface Refund {
54
+ id: string;
55
+ amount: Money;
56
+ /** `pending` is money on its way, not money that has arrived. */
57
+ status: RefundStatus;
58
+ reason: string;
59
+ created_at: string;
60
+ }
61
+
62
+ export interface Payment {
63
+ id: string;
64
+ /** False for a payment a test key asked for, at the provider's test mode. */
65
+ livemode: boolean;
66
+ status: PaymentStatus;
67
+ amount: Money;
68
+ /** What has gone back so far. */
69
+ refunded: Money;
70
+ /**
71
+ * What is still there to give back.
72
+ *
73
+ * Published rather than left to be worked out, because the arithmetic is not
74
+ * what it looks like: a refund still settling is money already spent, and
75
+ * subtracting only the settled ones would show more left than there is.
76
+ */
77
+ refundable: Money;
78
+ /**
79
+ * What this settled on the platform, as a model label and that object's id.
80
+ * `"bookings.booking"`, or empty on a standalone payment page.
81
+ */
82
+ subject_type: string;
83
+ subject_id: string | null;
84
+ customer: { email: string; name: string };
85
+ /**
86
+ * Where to send the customer to pay.
87
+ *
88
+ * Empty only when no page was ever opened, which is a provider that refused.
89
+ * It is not cleared once the payment settles, so read `status` to decide
90
+ * whether to show it: the provider refuses a session that has been paid, but
91
+ * a paid receipt with a pay button on it is still a bad page.
92
+ */
93
+ checkout_url: string;
94
+ checkout_expires_at: string | null;
95
+ created_at: string;
96
+ /** When the provider said the money arrived. Null until it did. */
97
+ paid_at: string | null;
98
+ /** Why nothing was taken. `checkout_failed` means no page was ever opened. */
99
+ failure_code: string;
100
+ failure_message: string;
101
+ refunds: Refund[];
102
+ }
103
+
104
+ export interface CheckoutRequest {
105
+ /** Minor units. 3500 is CHF 35.00. */
106
+ amount: number;
107
+ /** Defaults to the project's own currency, which is nearly always the answer. */
108
+ currency?: string;
109
+ /** What the customer reads on the payment page. */
110
+ description?: string;
111
+ customer_email?: string;
112
+ customer_name?: string;
113
+ /**
114
+ * Where the provider sends the customer afterwards. Your pages, so your URLs.
115
+ *
116
+ * The success page confirms nothing. It means a form was submitted, and only
117
+ * the provider's webhook says money moved.
118
+ */
119
+ success_url: string;
120
+ cancel_url: string;
121
+ /** Your own references, carried to the provider and back on every webhook. */
122
+ metadata?: Record<string, string>;
123
+ }