@backendfree/payments 0.0.0-stage → 0.1.1

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/README.md CHANGED
@@ -1,3 +1,132 @@
1
- # Temporary Holding Version
1
+ # @backendfree/payments
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A project's money: what has been taken, and taking some. Built on
4
+ [`@backendfree/core`](../core).
5
+
6
+ ```sh
7
+ npm install @backendfree/core @backendfree/payments
8
+ ```
9
+
10
+ ```ts
11
+ import { Client } from '@backendfree/core';
12
+ import { Payments } from '@backendfree/payments';
13
+
14
+ const payments = new Payments(
15
+ new Client({ origin: 'https://backendfree.com', key: process.env.BACKENDFREE_SECRET_KEY! }),
16
+ );
17
+
18
+ const payment = await payments.checkout({
19
+ amount: 3500, // minor units: CHF 35.00
20
+ description: 'Deposit',
21
+ success_url: 'https://shop.example/thanks?payment={id}',
22
+ cancel_url: 'https://shop.example/cart',
23
+ metadata: { order: 'A-1042' }, // your own references, returned on every read and event
24
+ });
25
+
26
+ redirect(payment.checkout_url);
27
+ ```
28
+
29
+ `{id}` in either address becomes the payment's id, which does not exist until
30
+ this call returns, so the page after paying can read the payment it was for.
31
+ `metadata` comes back on the payment and in every `payment.*` event about it,
32
+ so the webhook can find the order it paid for: at most 20 keys, each value text.
33
+
34
+ ## Server only
35
+
36
+ Every method here needs a secret key, and constructing `Payments` with a
37
+ publishable one throws. A payment carries the paying customer's name and email,
38
+ and a checkout is a charge on a bank account, so none of it belongs in a bundle
39
+ whoever loads the site can read.
40
+
41
+ That is also why the mistake is refused at construction rather than by a 403 in
42
+ production: the stack trace points at the line that is wrong.
43
+
44
+ ## Why there is no refund method
45
+
46
+ Refunding is the one act that moves money out of the business's own account, so
47
+ it lives in the payments screen in the dashboard, behind a capability only the
48
+ account owner holds.
49
+
50
+ A secret key lives in server code, deploy settings and CI, which is further than
51
+ the account owner's own screen. A leaked key that can read payment records is one
52
+ incident; one that could refund would drain the account. Reading payment records
53
+ with a key is deliberate and bounded, so a developer can render a receipt;
54
+ issuing money back is not the same thing and is not in the API.
55
+
56
+ ## Nothing here says a payment was made
57
+
58
+ A checkout is a page that exists. The provider's webhook is what says money
59
+ moved, and the page the customer lands on afterwards says nothing at all.
60
+
61
+ Take the platform's `payment.succeeded` webhook and verify it:
62
+
63
+ ```ts
64
+ import { verifyWebhook } from '@backendfree/core';
65
+
66
+ const event = await verifyWebhook({ body: await request.text(), headers, secret });
67
+ if (event.name === 'payment.succeeded') {
68
+ markPaid(event.data.metadata.order, event.data.amount, event.data.currency);
69
+ }
70
+ ```
71
+
72
+ ## When the provider refuses
73
+
74
+ `checkout()` does not throw for a provider that would not open a page. The
75
+ payment comes back with an empty `checkout_url` and the reason on it, so the
76
+ attempt is on record and you have an id to try again with.
77
+
78
+ ```ts
79
+ if (!payment.checkout_url) {
80
+ payment = await payments.offerAgain(payment.id, { success_url, cancel_url });
81
+ }
82
+ ```
83
+
84
+ `offerAgain` is keyed on the payment, so asking twice is one page, and what is
85
+ owed comes off the payment rather than off the call. Calling `checkout()` a
86
+ second time would record a second payment for one order, and a customer handed
87
+ both pages could pay twice.
88
+
89
+ ## Reading what has been taken
90
+
91
+ ```ts
92
+ await payments.list({ status: 'paid' });
93
+ await payments.payment(id);
94
+ await payments.forSubject('bookings.booking', bookingId); // was it paid for?
95
+ ```
96
+
97
+ A payment says what it settled, how much, what has gone back and what is left to
98
+ give back. It never says which provider account the money landed in: that is the
99
+ business's own and appears nowhere in this API.
100
+
101
+ `refundable` is published rather than left to you to work out, because the
102
+ arithmetic is not what it looks like. A refund that is still settling is money
103
+ already spent, so subtracting only the settled ones shows more left than there
104
+ is.
105
+
106
+ ## Money
107
+
108
+ Always minor units and a currency. `3500` with `CHF` is 35 francs.
109
+
110
+ ```ts
111
+ import { formatMoney } from '@backendfree/payments';
112
+
113
+ formatMoney({ amount: 6500, currency: 'CHF' }, 'de-CH'); // CHF 65.00
114
+ formatMoney({ amount: 6500, currency: 'JPY' }, 'en-US'); // ¥6,500
115
+ ```
116
+
117
+ Dividing by a hundred yourself is wrong in both directions: JPY has no minor
118
+ unit and KWD has three places. The number of places comes from the currency
119
+ rather than from a constant.
120
+
121
+ ## Reading a status
122
+
123
+ ```ts
124
+ import { hasPaid, isOver } from '@backendfree/payments';
125
+
126
+ hasPaid(payment); // the money arrived, refunded payments included
127
+ isOver(payment); // nothing further will happen, so do not offer a page
128
+ ```
129
+
130
+ A status only ever moves forward. A provider redelivers events for days and
131
+ promises no order, so the platform applies each one as a maximum and a late
132
+ "still pending" cannot undo a payment that arrived.
@@ -0,0 +1,132 @@
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
+ import type { Client, Page as Paginated, RequestOptions } from '@backendfree/core';
38
+ import type { CheckoutRequest, Money, Payment } from './types.js';
39
+ /** Which payments to list. Every filter is optional and they combine. */
40
+ export interface PaymentQuery extends RequestOptions {
41
+ status?: string;
42
+ /** For example `bookings.booking`. */
43
+ subject_type?: string;
44
+ /** That object's id, to answer "was this one paid for?". */
45
+ subject_id?: string;
46
+ /** Which page: 25 to a page unless asked for up to 100. */
47
+ page?: number;
48
+ page_size?: number;
49
+ }
50
+ /** Where to send the customer, when offering a page for a payment that exists. `{id}` becomes its id. */
51
+ export interface OfferOptions extends RequestOptions {
52
+ success_url: string;
53
+ cancel_url: string;
54
+ description?: string;
55
+ }
56
+ export declare class Payments {
57
+ #private;
58
+ constructor(client: Client);
59
+ /**
60
+ * Payments, newest first.
61
+ *
62
+ * Not cached, on purpose: a payment moves when the provider says so, and a
63
+ * stored answer would outlive the money it describes.
64
+ */
65
+ list(query?: PaymentQuery): Promise<Paginated<Payment>>;
66
+ payment(id: string, options?: RequestOptions): Promise<Payment>;
67
+ /**
68
+ * Whether something on the platform has been paid for, in one call.
69
+ *
70
+ * What a booking confirmation page asks. Returns the payments against that
71
+ * object, because there can be more than one: a deposit and a balance, or a
72
+ * first attempt that failed and a second that did not.
73
+ */
74
+ forSubject(subject_type: string, subject_id: string, options?: RequestOptions): Promise<Payment[]>;
75
+ /**
76
+ * Open a payment page for one amount.
77
+ *
78
+ * An invoice, a deposit taken over the phone, a donation button. It settles
79
+ * nothing on the platform and names no booking, which is why it cannot be
80
+ * used to mark somebody else's appointment paid.
81
+ *
82
+ * Retrying this call is safe: core puts an idempotency key on it, and the
83
+ * same key is always the same charge. Pass your own order id as
84
+ * `idempotencyKey` to make a retry from another process count as the same
85
+ * request.
86
+ *
87
+ * A provider that refuses does not fail the call. The payment comes back with
88
+ * an empty `checkout_url` and the reason in `failure_message`, so the attempt
89
+ * is on record and you have an id to try again with. `offerAgain` is what
90
+ * tries again; calling this a second time would be a second payment for one
91
+ * order.
92
+ */
93
+ checkout(request: CheckoutRequest, options?: RequestOptions): Promise<Payment>;
94
+ /**
95
+ * Offer the page again for a payment that already exists.
96
+ *
97
+ * What a customer needs when the provider refused, or when they closed the
98
+ * tab. Keyed on the payment rather than on a fresh idempotency key, so asking
99
+ * twice is one page.
100
+ *
101
+ * What is owed comes off the payment, never off this call: the price was
102
+ * agreed when the payment was made. A payment that is settled or over is
103
+ * answered rather than re-opened, because re-opening one is how somebody pays
104
+ * twice.
105
+ */
106
+ offerAgain(id: string, options: OfferOptions): Promise<Payment>;
107
+ }
108
+ /** Whether the money arrived. True of a refunded payment too: it did arrive. */
109
+ export declare function hasPaid(payment: Payment): boolean;
110
+ /**
111
+ * Whether nothing further will happen to this payment.
112
+ *
113
+ * What decides whether offering the page again is worth anything. A failed,
114
+ * cancelled, expired or fully refunded payment is over; a created or pending
115
+ * one can still be paid.
116
+ */
117
+ export declare function isOver(payment: Payment): boolean;
118
+ /**
119
+ * An amount, written the way a person in a given place writes it.
120
+ *
121
+ * The one formatting helper worth shipping, for the same reason bookings ships
122
+ * one for time: the obvious version is wrong. Dividing by a hundred is right
123
+ * for CHF, wrong for JPY, which has no minor unit at all, and wrong the other
124
+ * way for KWD, which has three. The number of places comes from the currency
125
+ * rather than from a constant, so this is correct for currencies nobody thought
126
+ * about when writing it.
127
+ *
128
+ * `locales` defaults to the reader's own. Pass one to match a page's language.
129
+ */
130
+ export declare function formatMoney(money: Money, locales?: string | string[]): string;
131
+ export type { CheckoutRequest, Money, Payment, PaymentStatus, Refund, RefundStatus, } from './types.js';
132
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,IAAI,SAAS,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAEnF,OAAO,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,YAAY,CAAC;AAElE,yEAAyE;AACzE,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,yGAAyG;AACzG,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,qBAAa,QAAQ;;gBAGP,MAAM,EAAE,MAAM;IAgB1B;;;;;OAKG;IACH,IAAI,CAAC,KAAK,GAAE,YAAiB,GAAG,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAQ3D,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,OAAO,CAAC;IAInE;;;;;;OAMG;IACG,UAAU,CACd,YAAY,EAAE,MAAM,EACpB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,cAAmB,GAC3B,OAAO,CAAC,OAAO,EAAE,CAAC;IAOrB;;;;;;;;;;;;;;;;;OAiBG;IACH,QAAQ,CAAC,OAAO,EAAE,eAAe,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,OAAO,CAAC;IAIlF;;;;;;;;;;;OAWG;IACH,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC;CAQhE;AAID,gFAAgF;AAChF,wBAAgB,OAAO,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAMjD;AAED;;;;;;GAMG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAOhD;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,MAAM,CAI7E;AAED,YAAY,EACV,eAAe,EACf,KAAK,EACL,OAAO,EACP,aAAa,EACb,MAAM,EACN,YAAY,GACb,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,154 @@
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
+ import { ConfigError } from '@backendfree/core';
38
+ export class Payments {
39
+ #client;
40
+ constructor(client) {
41
+ // Refused here rather than by the API, because the answer is the same
42
+ // either way and finding out at construction is a stack trace pointing at
43
+ // the line that is wrong.
44
+ if (client.publishable) {
45
+ throw new ConfigError('secret_key_required', 'Payments needs a secret key. A payment carries a customer name and email and a ' +
46
+ 'checkout is a charge on a bank account, so none of it is safe in a browser.');
47
+ }
48
+ this.#client = client;
49
+ }
50
+ // --- what has been taken -----------------------------------------------------
51
+ /**
52
+ * Payments, newest first.
53
+ *
54
+ * Not cached, on purpose: a payment moves when the provider says so, and a
55
+ * stored answer would outlive the money it describes.
56
+ */
57
+ list(query = {}) {
58
+ const { status, subject_type, subject_id, page, page_size, ...rest } = query;
59
+ return this.#client.get('/payments', {
60
+ ...rest,
61
+ query: { status, subject_type, subject_id, page, page_size, ...rest.query },
62
+ });
63
+ }
64
+ payment(id, options = {}) {
65
+ return this.#client.get(`/payments/${encodeURIComponent(id)}`, options);
66
+ }
67
+ /**
68
+ * Whether something on the platform has been paid for, in one call.
69
+ *
70
+ * What a booking confirmation page asks. Returns the payments against that
71
+ * object, because there can be more than one: a deposit and a balance, or a
72
+ * first attempt that failed and a second that did not.
73
+ */
74
+ async forSubject(subject_type, subject_id, options = {}) {
75
+ const { results } = await this.list({ ...options, subject_type, subject_id });
76
+ return results;
77
+ }
78
+ // --- taking some -------------------------------------------------------------
79
+ /**
80
+ * Open a payment page for one amount.
81
+ *
82
+ * An invoice, a deposit taken over the phone, a donation button. It settles
83
+ * nothing on the platform and names no booking, which is why it cannot be
84
+ * used to mark somebody else's appointment paid.
85
+ *
86
+ * Retrying this call is safe: core puts an idempotency key on it, and the
87
+ * same key is always the same charge. Pass your own order id as
88
+ * `idempotencyKey` to make a retry from another process count as the same
89
+ * request.
90
+ *
91
+ * A provider that refuses does not fail the call. The payment comes back with
92
+ * an empty `checkout_url` and the reason in `failure_message`, so the attempt
93
+ * is on record and you have an id to try again with. `offerAgain` is what
94
+ * tries again; calling this a second time would be a second payment for one
95
+ * order.
96
+ */
97
+ checkout(request, options = {}) {
98
+ return this.#client.post('/checkouts', request, options);
99
+ }
100
+ /**
101
+ * Offer the page again for a payment that already exists.
102
+ *
103
+ * What a customer needs when the provider refused, or when they closed the
104
+ * tab. Keyed on the payment rather than on a fresh idempotency key, so asking
105
+ * twice is one page.
106
+ *
107
+ * What is owed comes off the payment, never off this call: the price was
108
+ * agreed when the payment was made. A payment that is settled or over is
109
+ * answered rather than re-opened, because re-opening one is how somebody pays
110
+ * twice.
111
+ */
112
+ offerAgain(id, options) {
113
+ const { success_url, cancel_url, description, ...rest } = options;
114
+ return this.#client.post(`/payments/${encodeURIComponent(id)}/checkout`, { success_url, cancel_url, description }, rest);
115
+ }
116
+ }
117
+ // --- reading a payment ---------------------------------------------------------
118
+ /** Whether the money arrived. True of a refunded payment too: it did arrive. */
119
+ export function hasPaid(payment) {
120
+ return (payment.status === 'paid' ||
121
+ payment.status === 'partially_refunded' ||
122
+ payment.status === 'refunded');
123
+ }
124
+ /**
125
+ * Whether nothing further will happen to this payment.
126
+ *
127
+ * What decides whether offering the page again is worth anything. A failed,
128
+ * cancelled, expired or fully refunded payment is over; a created or pending
129
+ * one can still be paid.
130
+ */
131
+ export function isOver(payment) {
132
+ return (payment.status === 'failed' ||
133
+ payment.status === 'cancelled' ||
134
+ payment.status === 'expired' ||
135
+ payment.status === 'refunded');
136
+ }
137
+ /**
138
+ * An amount, written the way a person in a given place writes it.
139
+ *
140
+ * The one formatting helper worth shipping, for the same reason bookings ships
141
+ * one for time: the obvious version is wrong. Dividing by a hundred is right
142
+ * for CHF, wrong for JPY, which has no minor unit at all, and wrong the other
143
+ * way for KWD, which has three. The number of places comes from the currency
144
+ * rather than from a constant, so this is correct for currencies nobody thought
145
+ * about when writing it.
146
+ *
147
+ * `locales` defaults to the reader's own. Pass one to match a page's language.
148
+ */
149
+ export function formatMoney(money, locales) {
150
+ const format = new Intl.NumberFormat(locales, { style: 'currency', currency: money.currency });
151
+ const places = format.resolvedOptions().maximumFractionDigits ?? 2;
152
+ return format.format(money.amount / 10 ** places);
153
+ }
154
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,120 @@
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
+ export interface Money {
16
+ /** Minor units: 3500 is CHF 35.00. Never divide without the currency. */
17
+ amount: number;
18
+ currency: string;
19
+ }
20
+ /**
21
+ * Where one payment has got to.
22
+ *
23
+ * It only ever moves forward. A provider redelivers events for days and
24
+ * promises no order, so the platform applies a status as a maximum rather than
25
+ * as an assignment, and a late "still pending" cannot undo a payment that
26
+ * arrived.
27
+ *
28
+ * `created` is a payment whose page was never opened, which is a provider that
29
+ * refused rather than a customer who declined.
30
+ */
31
+ export type PaymentStatus = 'created' | 'pending' | 'requires_action' | 'paid' | 'failed' | 'cancelled' | 'expired' | 'partially_refunded' | 'refunded';
32
+ /**
33
+ * Where one refund has got to.
34
+ *
35
+ * Its own vocabulary rather than a corner of the payment's, because a refund
36
+ * succeeding and a payment being refunded are different facts: a payment can
37
+ * carry several refunds, and one of them failing does not move the payment.
38
+ */
39
+ export type RefundStatus = 'pending' | 'succeeded' | 'failed' | 'cancelled';
40
+ export interface Refund {
41
+ id: string;
42
+ amount: Money;
43
+ /** `pending` is money on its way, not money that has arrived. */
44
+ status: RefundStatus;
45
+ reason: string;
46
+ created_at: string;
47
+ }
48
+ export interface Payment {
49
+ id: string;
50
+ /** False for a payment a test key asked for, at the provider's test mode. */
51
+ livemode: boolean;
52
+ status: PaymentStatus;
53
+ amount: Money;
54
+ /** What has gone back so far. */
55
+ refunded: Money;
56
+ /**
57
+ * What is still there to give back.
58
+ *
59
+ * Published rather than left to be worked out, because the arithmetic is not
60
+ * what it looks like: a refund still settling is money already spent, and
61
+ * subtracting only the settled ones would show more left than there is.
62
+ */
63
+ refundable: Money;
64
+ /**
65
+ * What this settled on the platform, as a model label and that object's id.
66
+ * `"bookings.booking"`, or empty on a standalone payment page.
67
+ */
68
+ subject_type: string;
69
+ subject_id: string | null;
70
+ customer: {
71
+ email: string;
72
+ name: string;
73
+ };
74
+ /** Your own references, as the checkout was given them. `{}` when there were none. */
75
+ metadata: Record<string, string>;
76
+ /**
77
+ * Where to send the customer to pay.
78
+ *
79
+ * Empty only when no page was ever opened, which is a provider that refused.
80
+ * It is not cleared once the payment settles, so read `status` to decide
81
+ * whether to show it: the provider refuses a session that has been paid, but
82
+ * a paid receipt with a pay button on it is still a bad page.
83
+ */
84
+ checkout_url: string;
85
+ checkout_expires_at: string | null;
86
+ created_at: string;
87
+ /** When the provider said the money arrived. Null until it did. */
88
+ paid_at: string | null;
89
+ /** Why nothing was taken. `checkout_failed` means no page was ever opened. */
90
+ failure_code: string;
91
+ failure_message: string;
92
+ refunds: Refund[];
93
+ }
94
+ export interface CheckoutRequest {
95
+ /** Minor units. 3500 is CHF 35.00. */
96
+ amount: number;
97
+ /** Defaults to the project's own currency, which is nearly always the answer. */
98
+ currency?: string;
99
+ /** What the customer reads on the payment page. */
100
+ description?: string;
101
+ customer_email?: string;
102
+ customer_name?: string;
103
+ /**
104
+ * Where the provider sends the customer afterwards. Your pages, so your URLs.
105
+ * `{id}` in either becomes the payment's id, so the page after paying knows
106
+ * which payment it was: `https://shop.example/thanks?payment={id}`.
107
+ *
108
+ * The success page confirms nothing. It means a form was submitted, and only
109
+ * the provider's webhook says money moved.
110
+ */
111
+ success_url: string;
112
+ cancel_url: string;
113
+ /**
114
+ * Your own references: at most 20 keys, each value text. Returned on the
115
+ * payment and in every `payment.*` event about it, so a webhook handler can
116
+ * find its order without a second call.
117
+ */
118
+ metadata?: Record<string, string>;
119
+ }
120
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,KAAK;IACpB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,aAAa,GACrB,SAAS,GACT,SAAS,GACT,iBAAiB,GACjB,MAAM,GACN,QAAQ,GACR,WAAW,GACX,SAAS,GACT,oBAAoB,GACpB,UAAU,CAAC;AAEf;;;;;;GAMG;AACH,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,WAAW,CAAC;AAE5E,MAAM,WAAW,MAAM;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,KAAK,CAAC;IACd,iEAAiE;IACjE,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,6EAA6E;IAC7E,QAAQ,EAAE,OAAO,CAAC;IAClB,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,KAAK,CAAC;IACd,iCAAiC;IACjC,QAAQ,EAAE,KAAK,CAAC;IAChB;;;;;;OAMG;IACH,UAAU,EAAE,KAAK,CAAC;IAClB;;;OAGG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IAC1C,sFAAsF;IACtF,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC;;;;;;;OAOG;IACH,YAAY,EAAE,MAAM,CAAC;IACrB,mBAAmB,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,mEAAmE;IACnE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,8EAA8E;IAC9E,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,eAAe;IAC9B,sCAAsC;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;;;OAOG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC"}
package/dist/types.js ADDED
@@ -0,0 +1,16 @@
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
+ export {};
16
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG"}
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@backendfree/payments",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Typed access to a BackendFree project's money: what has been taken, and opening a payment page.",
5
+ "keywords": [
6
+ "backendfree",
7
+ "payments",
8
+ "checkout",
9
+ "stripe",
10
+ "sdk",
11
+ "typescript"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "BackendFree",
15
+ "type": "module",
16
+ "sideEffects": false,
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "!dist/.tsbuildinfo",
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "dependencies": {
39
+ "@backendfree/core": "^0.1.0"
40
+ },
41
+ "scripts": {
42
+ "clean": "tsc --build --clean && rm -rf dist",
43
+ "build": "tsc --build",
44
+ "test": "npm run build && node --test test/*.test.mjs",
45
+ "prepare": "npm run build",
46
+ "prepack": "npm run clean && npm run build",
47
+ "prepublishOnly": "npm test"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,213 @@
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
+ /** Which page: 25 to a page unless asked for up to 100. */
51
+ page?: number;
52
+ page_size?: number;
53
+ }
54
+
55
+ /** Where to send the customer, when offering a page for a payment that exists. `{id}` becomes its id. */
56
+ export interface OfferOptions extends RequestOptions {
57
+ success_url: string;
58
+ cancel_url: string;
59
+ description?: string;
60
+ }
61
+
62
+ export class Payments {
63
+ readonly #client: Client;
64
+
65
+ constructor(client: Client) {
66
+ // Refused here rather than by the API, because the answer is the same
67
+ // either way and finding out at construction is a stack trace pointing at
68
+ // the line that is wrong.
69
+ if (client.publishable) {
70
+ throw new ConfigError(
71
+ 'secret_key_required',
72
+ 'Payments needs a secret key. A payment carries a customer name and email and a ' +
73
+ 'checkout is a charge on a bank account, so none of it is safe in a browser.',
74
+ );
75
+ }
76
+ this.#client = client;
77
+ }
78
+
79
+ // --- what has been taken -----------------------------------------------------
80
+
81
+ /**
82
+ * Payments, newest first.
83
+ *
84
+ * Not cached, on purpose: a payment moves when the provider says so, and a
85
+ * stored answer would outlive the money it describes.
86
+ */
87
+ list(query: PaymentQuery = {}): Promise<Paginated<Payment>> {
88
+ const { status, subject_type, subject_id, page, page_size, ...rest } = query;
89
+ return this.#client.get<Paginated<Payment>>('/payments', {
90
+ ...rest,
91
+ query: { status, subject_type, subject_id, page, page_size, ...rest.query },
92
+ });
93
+ }
94
+
95
+ payment(id: string, options: RequestOptions = {}): Promise<Payment> {
96
+ return this.#client.get<Payment>(`/payments/${encodeURIComponent(id)}`, options);
97
+ }
98
+
99
+ /**
100
+ * Whether something on the platform has been paid for, in one call.
101
+ *
102
+ * What a booking confirmation page asks. Returns the payments against that
103
+ * object, because there can be more than one: a deposit and a balance, or a
104
+ * first attempt that failed and a second that did not.
105
+ */
106
+ async forSubject(
107
+ subject_type: string,
108
+ subject_id: string,
109
+ options: RequestOptions = {},
110
+ ): Promise<Payment[]> {
111
+ const { results } = await this.list({ ...options, subject_type, subject_id });
112
+ return results;
113
+ }
114
+
115
+ // --- taking some -------------------------------------------------------------
116
+
117
+ /**
118
+ * Open a payment page for one amount.
119
+ *
120
+ * An invoice, a deposit taken over the phone, a donation button. It settles
121
+ * nothing on the platform and names no booking, which is why it cannot be
122
+ * used to mark somebody else's appointment paid.
123
+ *
124
+ * Retrying this call is safe: core puts an idempotency key on it, and the
125
+ * same key is always the same charge. Pass your own order id as
126
+ * `idempotencyKey` to make a retry from another process count as the same
127
+ * request.
128
+ *
129
+ * A provider that refuses does not fail the call. The payment comes back with
130
+ * an empty `checkout_url` and the reason in `failure_message`, so the attempt
131
+ * is on record and you have an id to try again with. `offerAgain` is what
132
+ * tries again; calling this a second time would be a second payment for one
133
+ * order.
134
+ */
135
+ checkout(request: CheckoutRequest, options: RequestOptions = {}): Promise<Payment> {
136
+ return this.#client.post<Payment>('/checkouts', request, options);
137
+ }
138
+
139
+ /**
140
+ * Offer the page again for a payment that already exists.
141
+ *
142
+ * What a customer needs when the provider refused, or when they closed the
143
+ * tab. Keyed on the payment rather than on a fresh idempotency key, so asking
144
+ * twice is one page.
145
+ *
146
+ * What is owed comes off the payment, never off this call: the price was
147
+ * agreed when the payment was made. A payment that is settled or over is
148
+ * answered rather than re-opened, because re-opening one is how somebody pays
149
+ * twice.
150
+ */
151
+ offerAgain(id: string, options: OfferOptions): Promise<Payment> {
152
+ const { success_url, cancel_url, description, ...rest } = options;
153
+ return this.#client.post<Payment>(
154
+ `/payments/${encodeURIComponent(id)}/checkout`,
155
+ { success_url, cancel_url, description },
156
+ rest,
157
+ );
158
+ }
159
+ }
160
+
161
+ // --- reading a payment ---------------------------------------------------------
162
+
163
+ /** Whether the money arrived. True of a refunded payment too: it did arrive. */
164
+ export function hasPaid(payment: Payment): boolean {
165
+ return (
166
+ payment.status === 'paid' ||
167
+ payment.status === 'partially_refunded' ||
168
+ payment.status === 'refunded'
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Whether nothing further will happen to this payment.
174
+ *
175
+ * What decides whether offering the page again is worth anything. A failed,
176
+ * cancelled, expired or fully refunded payment is over; a created or pending
177
+ * one can still be paid.
178
+ */
179
+ export function isOver(payment: Payment): boolean {
180
+ return (
181
+ payment.status === 'failed' ||
182
+ payment.status === 'cancelled' ||
183
+ payment.status === 'expired' ||
184
+ payment.status === 'refunded'
185
+ );
186
+ }
187
+
188
+ /**
189
+ * An amount, written the way a person in a given place writes it.
190
+ *
191
+ * The one formatting helper worth shipping, for the same reason bookings ships
192
+ * one for time: the obvious version is wrong. Dividing by a hundred is right
193
+ * for CHF, wrong for JPY, which has no minor unit at all, and wrong the other
194
+ * way for KWD, which has three. The number of places comes from the currency
195
+ * rather than from a constant, so this is correct for currencies nobody thought
196
+ * about when writing it.
197
+ *
198
+ * `locales` defaults to the reader's own. Pass one to match a page's language.
199
+ */
200
+ export function formatMoney(money: Money, locales?: string | string[]): string {
201
+ const format = new Intl.NumberFormat(locales, { style: 'currency', currency: money.currency });
202
+ const places = format.resolvedOptions().maximumFractionDigits ?? 2;
203
+ return format.format(money.amount / 10 ** places);
204
+ }
205
+
206
+ export type {
207
+ CheckoutRequest,
208
+ Money,
209
+ Payment,
210
+ PaymentStatus,
211
+ Refund,
212
+ RefundStatus,
213
+ } from './types.js';
package/src/types.ts ADDED
@@ -0,0 +1,131 @@
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
+ /** Your own references, as the checkout was given them. `{}` when there were none. */
86
+ metadata: Record<string, string>;
87
+ /**
88
+ * Where to send the customer to pay.
89
+ *
90
+ * Empty only when no page was ever opened, which is a provider that refused.
91
+ * It is not cleared once the payment settles, so read `status` to decide
92
+ * whether to show it: the provider refuses a session that has been paid, but
93
+ * a paid receipt with a pay button on it is still a bad page.
94
+ */
95
+ checkout_url: string;
96
+ checkout_expires_at: string | null;
97
+ created_at: string;
98
+ /** When the provider said the money arrived. Null until it did. */
99
+ paid_at: string | null;
100
+ /** Why nothing was taken. `checkout_failed` means no page was ever opened. */
101
+ failure_code: string;
102
+ failure_message: string;
103
+ refunds: Refund[];
104
+ }
105
+
106
+ export interface CheckoutRequest {
107
+ /** Minor units. 3500 is CHF 35.00. */
108
+ amount: number;
109
+ /** Defaults to the project's own currency, which is nearly always the answer. */
110
+ currency?: string;
111
+ /** What the customer reads on the payment page. */
112
+ description?: string;
113
+ customer_email?: string;
114
+ customer_name?: string;
115
+ /**
116
+ * Where the provider sends the customer afterwards. Your pages, so your URLs.
117
+ * `{id}` in either becomes the payment's id, so the page after paying knows
118
+ * which payment it was: `https://shop.example/thanks?payment={id}`.
119
+ *
120
+ * The success page confirms nothing. It means a form was submitted, and only
121
+ * the provider's webhook says money moved.
122
+ */
123
+ success_url: string;
124
+ cancel_url: string;
125
+ /**
126
+ * Your own references: at most 20 keys, each value text. Returned on the
127
+ * payment and in every `payment.*` event about it, so a webhook handler can
128
+ * find its order without a second call.
129
+ */
130
+ metadata?: Record<string, string>;
131
+ }